feat(bugcollect): implement BugCollect API v1.1.0 full stack

- LogController: 新增 /issues/{id}/events, /issues/{id}/trend, 管理接口
  (resolve/ignore/assign/bulk),queryIssues 改用 level/status/q 参数
- LogService: 全面重写,eventId 幂等、breadcrumbs 存储、affectedUsers 计数、
  Issue status 管理、getIssueEvents/getIssueTrend 独立接口
- Entity: LogIssueEventEntity 增加 eventId/exceptionType/exceptionValue/breadcrumbs;
  LogIssueEntity 增加 status/affectedUsers/assignee;LogEventEntity 增加 eventId
- Repository: LogIssueEventRepository/LogIssueRepository/LogEventRepository 新增
  idempotency/filter/trend/bulk 查询方法
- DTO: IssueActionRequest/IssueTrendResponse 新增;IssueResponse/IssueEventResponse 扩展
- V3 migration: log_issue_events/log_issues/log_events 结构升级

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-17 15:30:05 +08:00
父节点 92f2c7c88b
当前提交 198dc7f960
共有 16 个文件被更改,包括 2000 次插入172 次删除

文件差异内容过多而无法显示 加载差异

查看文件

@ -28,6 +28,8 @@ public class LogController {
this.webhookService = webhookService;
}
// Ingestion
@PostMapping("/issues/batch")
public ApiResponse<Void> ingestIssues(@Valid @RequestBody IssueBatchRequest request) {
logService.processIssueBatch(request);
@ -40,16 +42,20 @@ public class LogController {
return ApiResponse.ok();
}
// Issues
@GetMapping("/issues")
public ApiResponse<PageResult<IssueResponse>> queryIssues(
@RequestParam String appKey,
@RequestParam(required = false) String type,
@RequestParam(required = false) String level,
@RequestParam(required = false) String platform,
@RequestParam(required = false) String status,
@RequestParam(required = false) String q,
@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, type, platform, from, to, page - 1, size);
Page<IssueResponse> result = logService.queryIssues(appKey, level, platform, status, q, from, to, page - 1, size);
return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size));
}
@ -58,6 +64,63 @@ public class LogController {
return ApiResponse.success(logService.getIssueDetail(id));
}
@GetMapping("/issues/{id}/events")
public ApiResponse<PageResult<IssueEventResponse>> getIssueEvents(
@PathVariable Long id,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
Page<IssueEventResponse> result = logService.getIssueEvents(id, from, to, page - 1, size);
return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size));
}
@GetMapping("/issues/{id}/trend")
public ApiResponse<IssueTrendResponse> getIssueTrend(
@PathVariable Long id,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
return ApiResponse.success(logService.getIssueTrend(id, from, to));
}
@DeleteMapping("/issues/{id}")
public ApiResponse<Void> deleteIssue(@PathVariable Long id) {
logService.deleteIssueAndEvents(id);
return ApiResponse.ok();
}
// Issue management
@PutMapping("/issues/{id}/resolve")
public ApiResponse<Void> resolveIssue(@PathVariable Long id) {
logService.resolveIssue(id);
return ApiResponse.ok();
}
@PutMapping("/issues/{id}/ignore")
public ApiResponse<Void> ignoreIssue(@PathVariable Long id) {
logService.ignoreIssue(id);
return ApiResponse.ok();
}
@PutMapping("/issues/{id}/assign")
public ApiResponse<Void> assignIssue(
@PathVariable Long id,
@RequestParam String assignee) {
logService.assignIssue(id, assignee);
return ApiResponse.ok();
}
@PostMapping("/issues/bulk")
public ApiResponse<Void> bulkUpdateIssues(
@RequestParam String appKey,
@Valid @RequestBody IssueActionRequest request) {
logService.bulkUpdateIssues(appKey, request.ids(), request.action());
return ApiResponse.ok();
}
// Rankings
@GetMapping("/issues/rankings/frequency")
public ApiResponse<List<IssueResponse>> getFrequencyRankings(
@RequestParam String appKey,
@ -76,6 +139,8 @@ public class LogController {
return ApiResponse.success(logService.getRiskRankings(appKey, from, to, limit));
}
// Analytics events
@GetMapping("/events")
public ApiResponse<PageResult<IssueEventResponse>> queryEvents(
@RequestParam String appKey,
@ -95,10 +160,11 @@ public class LogController {
@RequestParam String steps,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to) {
List<String> stepList = List.of(steps.split(","));
return ApiResponse.success(logService.queryFunnel(appKey, stepList, from, to));
return ApiResponse.success(logService.queryFunnel(appKey, List.of(steps.split(",")), from, to));
}
// Overview
@GetMapping("/overview")
public ApiResponse<OverviewResponse> getOverview(
@RequestParam String appKey,
@ -107,6 +173,8 @@ public class LogController {
return ApiResponse.success(logService.getOverview(appKey, from, to));
}
// Sourcemaps
@PostMapping("/sourcemaps/upload")
public ApiResponse<SourcemapUploadResponse> uploadSourcemap(
@RequestParam String appKey,
@ -117,6 +185,8 @@ public class LogController {
return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleName, file));
}
// Webhooks
@GetMapping("/webhooks")
public ApiResponse<List<WebhookResponse>> listWebhooks(@RequestParam String appKey) {
return ApiResponse.success(webhookService.listWebhooks(appKey));

查看文件

@ -6,16 +6,21 @@ import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record EventBatchRequest(
@JsonProperty("sentAt") String sentAt,
@JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk,
@JsonProperty("events") @NotEmpty List<EventItem> events
) {
public record EventItem(
@JsonProperty("eventId") String eventId,
@NotBlank @JsonProperty("appKey") String appKey,
@NotBlank String name,
@JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId,
String properties,
long timestamp,
@JsonProperty("properties") Object properties,
String platform,
@JsonProperty("appVersion") String appVersion,
@JsonProperty("timestamp") long timestamp
String release,
String environment,
@JsonProperty("user") IssueBatchRequest.UserInfo user,
@JsonProperty("device") IssueBatchRequest.DeviceInfo device,
@JsonProperty("sdk") IssueBatchRequest.SdkInfo sdk
) {}
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.bugcollect.dto;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
public record IssueActionRequest(
@NotEmpty List<Long> ids,
String action,
String assignee
) {}

查看文件

@ -4,20 +4,63 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
import java.util.Map;
public record IssueBatchRequest(
@JsonProperty("sentAt") String sentAt,
@JsonProperty("sdk") SdkInfo sdk,
@JsonProperty("events") @NotEmpty List<IssueEventItem> events
) {
public record SdkInfo(
String name,
String version
) {}
public record IssueEventItem(
@NotBlank String type,
String message,
String stack,
@NotBlank String fingerprint,
@JsonProperty("timestamp") long timestamp,
@JsonProperty("userId") String userId,
@JsonProperty("sessionId") String sessionId,
@JsonProperty("eventId") String eventId,
@NotBlank @JsonProperty("appKey") String appKey,
String platform,
@JsonProperty("appVersion") String appVersion
@NotBlank String level,
@NotBlank String platform,
@NotBlank String fingerprint,
long timestamp,
@JsonProperty("exception") ExceptionInfo exception,
@JsonProperty("breadcrumbs") List<BreadcrumbItem> breadcrumbs,
@JsonProperty("user") UserInfo user,
@JsonProperty("device") DeviceInfo device,
String release,
String environment,
@JsonProperty("tags") Object tags
) {}
public record ExceptionInfo(
String type,
String value,
String stacktrace
) {}
public record BreadcrumbItem(
long timestamp,
String category,
String message,
String level,
@JsonProperty("data") Map<String, Object> data
) {}
public record UserInfo(
String id
) {}
public record DeviceInfo(
String name,
String model,
String manufacturer,
String osName,
String osVersion,
String locale,
String timezone,
String network,
Boolean isEmulator,
Integer freeMemoryMb,
String buildType
) {}
}

查看文件

@ -6,14 +6,22 @@ import java.time.LocalDateTime;
public record IssueEventResponse(
Long id,
@JsonProperty("issueId") Long issueId,
@JsonProperty("eventId") String eventId,
@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 metadata,
String breadcrumbs,
String tags,
String device,
String platform,
@JsonProperty("appVersion") String appVersion,
String release,
String environment,
@JsonProperty("sdkName") String sdkName,
@JsonProperty("sdkVersion") String sdkVersion,
@JsonProperty("createdAt") LocalDateTime createdAt
) {}

查看文件

@ -8,13 +8,16 @@ public record IssueResponse(
Long id,
@JsonProperty("appKey") String appKey,
String fingerprint,
String type,
String level,
String status,
String title,
@JsonProperty("firstSeenAt") LocalDateTime firstSeenAt,
@JsonProperty("lastSeenAt") LocalDateTime lastSeenAt,
int count,
@JsonProperty("affectedUsers") int affectedUsers,
@JsonProperty("isResolved") boolean isResolved,
String assignee,
String platform,
@JsonProperty("appVersion") String appVersion,
String release,
List<IssueEventResponse> events
) {}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.bugcollect.dto;
import java.util.List;
public record IssueTrendResponse(
Long issueId,
List<TrendPoint> points
) {
public record TrendPoint(String date, long count, long affectedUsers) {}
}

查看文件

@ -11,6 +11,9 @@ public class LogEventEntity {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 64)
private String eventId;
@Column(nullable = false, length = 64)
private String appKey;
@ -29,8 +32,20 @@ public class LogEventEntity {
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
@Column(name = "app_version", length = 32)
private String release;
@Column(length = 50)
private String environment;
@Column(columnDefinition = "JSON")
private String device;
@Column(length = 100)
private String sdkName;
@Column(length = 20)
private String sdkVersion;
@Column(nullable = false)
private LocalDateTime createdAt;
@ -38,6 +53,9 @@ public class LogEventEntity {
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
@ -56,8 +74,20 @@ public class LogEventEntity {
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public String getRelease() { return release; }
public void setRelease(String release) { this.release = release; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public String getDevice() { return device; }
public void setDevice(String device) { this.device = device; }
public String getSdkName() { return sdkName; }
public void setSdkName(String sdkName) { this.sdkName = sdkName; }
public String getSdkVersion() { return sdkVersion; }
public void setSdkVersion(String sdkVersion) { this.sdkVersion = sdkVersion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -20,6 +20,13 @@ public class LogIssueEntity {
@Column(nullable = false, length = 32)
private String type;
@Column(nullable = false, length = 20)
private String level;
/** open | resolved | ignored */
@Column(nullable = false, length = 20)
private String status = "open";
@Column(nullable = false, length = 500)
private String title;
@ -32,14 +39,20 @@ public class LogIssueEntity {
@Column(nullable = false)
private int count = 1;
@Column(nullable = false)
private int affectedUsers = 0;
@Column(nullable = false, columnDefinition = "TINYINT(1)")
private boolean isResolved = false;
@Column(length = 200)
private String assignee;
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
@Column(name = "app_version", length = 32)
private String release;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@ -53,6 +66,15 @@ public class LogIssueEntity {
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public String getStatus() { return status; }
public void setStatus(String status) {
this.status = status;
this.isResolved = "resolved".equals(status);
}
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
@ -65,12 +87,21 @@ public class LogIssueEntity {
public int getCount() { return count; }
public void setCount(int count) { this.count = count; }
public int getAffectedUsers() { return affectedUsers; }
public void setAffectedUsers(int affectedUsers) { this.affectedUsers = affectedUsers; }
public boolean isResolved() { return isResolved; }
public void setResolved(boolean resolved) { isResolved = resolved; }
public void setResolved(boolean resolved) {
isResolved = resolved;
if (resolved && !"resolved".equals(this.status)) this.status = "resolved";
}
public String getAssignee() { return assignee; }
public void setAssignee(String assignee) { this.assignee = assignee; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public String getRelease() { return release; }
public void setRelease(String release) { this.release = release; }
}

查看文件

@ -14,15 +14,27 @@ public class LogIssueEventEntity {
@Column(nullable = false)
private Long issueId;
@Column(length = 64)
private String eventId;
@Column(nullable = false, length = 64)
private String appKey;
@Column(length = 20)
private String level;
@Column(length = 128)
private String userId;
@Column(length = 128)
private String sessionId;
@Column(length = 200)
private String exceptionType;
@Column(columnDefinition = "TEXT")
private String exceptionValue;
@Column(columnDefinition = "TEXT")
private String message;
@ -33,13 +45,28 @@ public class LogIssueEventEntity {
private String stackSymbolicated;
@Column(columnDefinition = "JSON")
private String metadata;
private String breadcrumbs;
@Column(columnDefinition = "JSON")
private String tags;
@Column(length = 16)
private String platform;
@Column(length = 32)
private String appVersion;
@Column(name = "app_version", length = 32)
private String release;
@Column(length = 50)
private String environment;
@Column(columnDefinition = "JSON")
private String device;
@Column(length = 100)
private String sdkName;
@Column(length = 20)
private String sdkVersion;
@Column(nullable = false)
private LocalDateTime createdAt;
@ -50,15 +77,27 @@ public class LogIssueEventEntity {
public Long getIssueId() { return issueId; }
public void setIssueId(Long issueId) { this.issueId = issueId; }
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getExceptionType() { return exceptionType; }
public void setExceptionType(String exceptionType) { this.exceptionType = exceptionType; }
public String getExceptionValue() { return exceptionValue; }
public void setExceptionValue(String exceptionValue) { this.exceptionValue = exceptionValue; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
@ -68,14 +107,29 @@ public class LogIssueEventEntity {
public String getStackSymbolicated() { return stackSymbolicated; }
public void setStackSymbolicated(String stackSymbolicated) { this.stackSymbolicated = stackSymbolicated; }
public String getMetadata() { return metadata; }
public void setMetadata(String metadata) { this.metadata = metadata; }
public String getBreadcrumbs() { return breadcrumbs; }
public void setBreadcrumbs(String breadcrumbs) { this.breadcrumbs = breadcrumbs; }
public String getTags() { return tags; }
public void setTags(String tags) { this.tags = tags; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public String getRelease() { return release; }
public void setRelease(String release) { this.release = release; }
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public String getDevice() { return device; }
public void setDevice(String device) { this.device = device; }
public String getSdkName() { return sdkName; }
public void setSdkName(String sdkName) { this.sdkName = sdkName; }
public String getSdkVersion() { return sdkVersion; }
public void setSdkVersion(String sdkVersion) { this.sdkVersion = sdkVersion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -12,6 +12,8 @@ import java.util.List;
public interface LogEventRepository extends JpaRepository<LogEventEntity, Long> {
boolean existsByAppKeyAndEventId(String appKey, String eventId);
Page<LogEventEntity> findByAppKeyAndNameAndUserIdAndCreatedAtBetween(
String appKey, String name, String userId,
LocalDateTime from, LocalDateTime to,

查看文件

@ -4,17 +4,35 @@ import com.xuqm.bugcollect.entity.LogIssueEventEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
public interface LogIssueEventRepository extends JpaRepository<LogIssueEventEntity, Long> {
boolean existsByAppKeyAndEventId(String appKey, String eventId);
List<LogIssueEventEntity> findTop20ByIssueIdOrderByCreatedAtDesc(Long issueId);
Page<LogIssueEventEntity> findByIssueIdOrderByCreatedAtDesc(Long issueId, Pageable pageable);
Page<LogIssueEventEntity> findByIssueIdAndCreatedAtBetweenOrderByCreatedAtDesc(
Long issueId, LocalDateTime from, LocalDateTime to, Pageable pageable);
Page<LogIssueEventEntity> findByAppKeyAndCreatedAtBetween(
String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable);
@Query("SELECT e.createdAt, COUNT(e), COUNT(DISTINCT e.userId) FROM LogIssueEventEntity e " +
"WHERE e.issueId = :issueId AND e.createdAt BETWEEN :from AND :to " +
"GROUP BY FUNCTION('DATE', e.createdAt) ORDER BY FUNCTION('DATE', e.createdAt)")
List<Object[]> findDailyCountsByIssueId(
@Param("issueId") Long issueId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
void deleteByIssueId(Long issueId);
void deleteByAppKey(String appKey);

查看文件

@ -20,6 +20,25 @@ 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 (:from IS NULL OR i.lastSeenAt >= :from) " +
"AND (:to IS NULL OR i.lastSeenAt <= :to)")
Page<LogIssueEntity> findByFilters(
@Param("appKey") String appKey,
@Param("level") String level,
@Param("platform") String platform,
@Param("status") String status,
@Param("q") String q,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
// kept for legacy callers
Page<LogIssueEntity> findByAppKeyAndTypeAndPlatformAndLastSeenAtBetween(
String appKey, String type, String platform,
LocalDateTime from, LocalDateTime to,
@ -30,16 +49,20 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
Page<LogIssueEntity> findByAppKey(String appKey, Pageable pageable);
@Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey AND i.lastSeenAt BETWEEN :from AND :to ORDER BY i.count DESC")
@Query("SELECT i FROM LogIssueEntity i WHERE i.appKey = :appKey " +
"AND (:from IS NULL OR i.lastSeenAt >= :from) " +
"AND (:to IS NULL OR i.lastSeenAt <= :to) ORDER BY i.count DESC")
List<LogIssueEntity> findTopByFrequency(@Param("appKey") String appKey,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
Pageable pageable);
@Query(value = "SELECT i.*, " +
"(i.count * COALESCE((SELECT COUNT(DISTINCT e.user_id) FROM log_issue_events e WHERE e.issue_id = i.id AND e.user_id IS NOT NULL), 1) * " +
"CASE i.type WHEN 'native_crash' THEN 10 WHEN 'js_error' THEN 5 WHEN 'api_error' THEN 3 ELSE 1 END) AS risk_score " +
"FROM log_issues i WHERE i.app_key = :appKey AND i.last_seen_at BETWEEN :from AND :to " +
"(i.count * COALESCE(i.affected_users, 1) * " +
"CASE i.level WHEN 'fatal' THEN 10 WHEN 'error' THEN 5 WHEN 'warning' THEN 2 ELSE 1 END) AS risk_score " +
"FROM log_issues i WHERE i.app_key = :appKey " +
"AND (:from IS NULL OR i.last_seen_at >= :from) " +
"AND (:to IS NULL OR i.last_seen_at <= :to) " +
"ORDER BY risk_score DESC",
nativeQuery = true)
List<LogIssueEntity> findTopByRisk(@Param("appKey") String appKey,
@ -51,6 +74,16 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
long countByAppKeyAndFirstSeenAtAfter(String appKey, LocalDateTime since);
long countByAppKeyAndStatus(String appKey, String status);
@Modifying
@Query("UPDATE LogIssueEntity i SET i.status = :status, i.isResolved = (:status = 'resolved') WHERE i.id IN :ids AND i.appKey = :appKey")
int bulkUpdateStatus(@Param("appKey") String appKey, @Param("ids") List<Long> ids, @Param("status") String status);
@Modifying
@Query("UPDATE LogIssueEntity i SET i.assignee = :assignee WHERE i.id = :id")
int updateAssignee(@Param("id") Long id, @Param("assignee") String assignee);
@Query("SELECT DISTINCT i.platform FROM LogIssueEntity i WHERE i.appKey = :appKey")
List<String> findDistinctPlatforms(@Param("appKey") String appKey);

查看文件

@ -19,9 +19,7 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class LogService {
@ -31,20 +29,17 @@ public class LogService {
private final LogIssueRepository issueRepository;
private final LogIssueEventRepository issueEventRepository;
private final LogEventRepository eventRepository;
private final LogWebhookRepository webhookRepository;
private final WebhookService webhookService;
private final ObjectMapper objectMapper;
public LogService(LogIssueRepository issueRepository,
LogIssueEventRepository issueEventRepository,
LogEventRepository eventRepository,
LogWebhookRepository webhookRepository,
WebhookService webhookService,
ObjectMapper objectMapper) {
this.issueRepository = issueRepository;
this.issueEventRepository = issueEventRepository;
this.eventRepository = eventRepository;
this.webhookRepository = webhookRepository;
this.webhookService = webhookService;
this.objectMapper = objectMapper;
}
@ -61,45 +56,75 @@ public class LogService {
}
private void processSingleIssue(IssueBatchRequest.IssueEventItem item) {
if (item.eventId() != null && !item.eventId().isBlank()
&& issueEventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
log.debug("Skipping duplicate eventId={} appKey={}", item.eventId(), item.appKey());
return;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime eventTime = Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime();
LocalDateTime eventTime = item.timestamp() > 0
? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime()
: now;
String exType = item.exception() != null ? item.exception().type() : "";
String exValue = item.exception() != null ? item.exception().value() : "";
String stack = item.exception() != null ? item.exception().stacktrace() : "";
String title = truncate(exType + (!exValue.isEmpty() ? ": " + exValue : ""), 500);
String userId = item.user() != null ? item.user().id() : null;
Optional<LogIssueEntity> existing = issueRepository.findByAppKeyAndFingerprint(item.appKey(), item.fingerprint());
LogIssueEntity issue;
boolean isNew = false;
if (existing.isPresent()) {
issue = existing.get();
issueRepository.incrementCount(item.appKey(), item.fingerprint(), now);
issue.setLastSeenAt(now);
issue.setCount(issue.getCount() + 1);
if (userId != null) issue.setAffectedUsers(issue.getAffectedUsers() + 1);
if (!"open".equals(issue.getStatus())) issue.setStatus("open");
issueRepository.save(issue);
} else {
issue = new LogIssueEntity();
issue.setAppKey(item.appKey());
issue.setFingerprint(item.fingerprint());
issue.setType(item.type());
issue.setTitle(truncate(item.message(), 500));
issue.setLevel(item.level());
issue.setType(item.level()); // backward compat column
issue.setStatus("open");
issue.setTitle(title);
issue.setFirstSeenAt(eventTime);
issue.setLastSeenAt(now);
issue.setCount(1);
issue.setAffectedUsers(userId != null ? 1 : 0);
issue.setPlatform(item.platform());
issue.setAppVersion(item.appVersion());
issue.setRelease(item.release());
issue = issueRepository.save(issue);
isNew = true;
}
LogIssueEventEntity eventEntity = new LogIssueEventEntity();
eventEntity.setIssueId(issue.getId());
eventEntity.setEventId(item.eventId());
eventEntity.setAppKey(item.appKey());
eventEntity.setUserId(item.userId());
eventEntity.setSessionId(item.sessionId());
eventEntity.setMessage(item.message());
eventEntity.setStack(item.stack());
eventEntity.setLevel(item.level());
eventEntity.setUserId(userId);
eventEntity.setExceptionType(exType);
eventEntity.setExceptionValue(exValue);
eventEntity.setMessage(exValue);
eventEntity.setStack(stack);
eventEntity.setPlatform(item.platform());
eventEntity.setAppVersion(item.appVersion());
eventEntity.setRelease(item.release());
eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production");
eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null);
eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null);
if (item.breadcrumbs() != null && !item.breadcrumbs().isEmpty()) {
eventEntity.setBreadcrumbs(toJson(item.breadcrumbs()));
}
eventEntity.setCreatedAt(eventTime);
issueEventRepository.save(eventEntity);
triggerWebhookAsync(issue);
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.appVersion());
if (isNew) triggerWebhookAsync(issue);
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.release());
}
@Async
@ -112,23 +137,30 @@ public class LogService {
}
@Async
void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String appVersion) {
// Symbolication is triggered asynchronously; actual implementation depends on SourceMap availability
log.debug("Symbolication triggered for issueId={}, appKey={}, platform={}, version={}", issueId, appKey, platform, appVersion);
void triggerSymbolicationAsync(Long issueId, String appKey, String platform, String release) {
log.debug("Symbolication triggered issueId={} appKey={} platform={} release={}", issueId, appKey, platform, release);
}
@Transactional
public void processEventBatch(EventBatchRequest request) {
for (EventBatchRequest.EventItem item : request.events()) {
try {
if (item.eventId() != null && !item.eventId().isBlank()
&& eventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
continue;
}
LogEventEntity entity = new LogEventEntity();
entity.setEventId(item.eventId());
entity.setAppKey(item.appKey());
entity.setName(item.name());
entity.setUserId(item.userId());
entity.setSessionId(item.sessionId());
entity.setProperties(item.properties());
entity.setUserId(item.user() != null ? item.user().id() : null);
entity.setProperties(item.properties() != null ? toJson(item.properties()) : null);
entity.setPlatform(item.platform());
entity.setAppVersion(item.appVersion());
entity.setRelease(item.release());
entity.setEnvironment(item.environment() != null ? item.environment() : "production");
entity.setDevice(item.device() != null ? toJson(item.device()) : null);
entity.setSdkName(item.sdk() != null ? item.sdk().name() : null);
entity.setSdkVersion(item.sdk() != null ? item.sdk().version() : null);
entity.setCreatedAt(
item.timestamp() > 0
? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime()
@ -142,20 +174,12 @@ public class LogService {
}
@Transactional(readOnly = true)
public Page<IssueResponse> queryIssues(String appKey, String type, String platform,
public Page<IssueResponse> queryIssues(String appKey, String level, String platform,
String status, String q,
String from, String to, int page, int size) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
Page<LogIssueEntity> result;
if (fromDate != null && toDate != null) {
result = issueRepository.findByAppKeyAndTypeAndPlatformAndLastSeenAtBetween(
appKey, type, platform, fromDate, toDate, pageable);
} else {
result = issueRepository.findByAppKey(appKey, pageable);
}
Page<LogIssueEntity> result = issueRepository.findByFilters(
appKey, level, platform, status, q, parseDate(from), parseDate(to), pageable);
return result.map(this::toIssueResponse);
}
@ -163,101 +187,130 @@ public class LogService {
public IssueResponse getIssueDetail(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
return toIssueResponse(issue);
}
List<LogIssueEventEntity> events = issueEventRepository.findTop20ByIssueIdOrderByCreatedAtDesc(id);
List<IssueEventResponse> eventResponses = events.stream()
.map(this::toIssueEventResponse)
@Transactional(readOnly = true)
public Page<IssueEventResponse> getIssueEvents(Long issueId, String from, String to, int page, int size) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("Issue not found: " + issueId);
}
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : null;
Page<LogIssueEventEntity> result = (fromDate != null && toDate != null)
? issueEventRepository.findByIssueIdAndCreatedAtBetweenOrderByCreatedAtDesc(issueId, fromDate, toDate, pageable)
: issueEventRepository.findByIssueIdOrderByCreatedAtDesc(issueId, pageable);
return result.map(this::toIssueEventResponse);
}
@Transactional(readOnly = true)
public IssueTrendResponse getIssueTrend(Long issueId, String from, String to) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("Issue not found: " + issueId);
}
LocalDate fromDate = from != null ? LocalDate.parse(from) : LocalDate.now().minusDays(13);
LocalDate toDate = to != null ? LocalDate.parse(to) : LocalDate.now();
List<Object[]> rows = issueEventRepository.findDailyCountsByIssueId(
issueId, fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay());
Map<String, long[]> dayMap = new LinkedHashMap<>();
for (LocalDate c = fromDate; !c.isAfter(toDate); c = c.plusDays(1)) {
dayMap.put(c.toString(), new long[]{0, 0});
}
for (Object[] row : rows) {
LocalDateTime dt = (LocalDateTime) row[0];
String day = dt.toLocalDate().toString();
dayMap.put(day, new long[]{((Number) row[1]).longValue(), ((Number) row[2]).longValue()});
}
List<IssueTrendResponse.TrendPoint> points = dayMap.entrySet().stream()
.map(e -> new IssueTrendResponse.TrendPoint(e.getKey(), e.getValue()[0], e.getValue()[1]))
.toList();
return new IssueTrendResponse(issueId, points);
}
return new IssueResponse(
issue.getId(), issue.getAppKey(), issue.getFingerprint(),
issue.getType(), issue.getTitle(),
issue.getFirstSeenAt(), issue.getLastSeenAt(),
issue.getCount(), issue.isResolved(),
issue.getPlatform(), issue.getAppVersion(),
eventResponses
);
@Transactional
public void resolveIssue(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
issue.setStatus("resolved");
issueRepository.save(issue);
}
@Transactional
public void ignoreIssue(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
issue.setStatus("ignored");
issueRepository.save(issue);
}
@Transactional
public void assignIssue(Long id, String assignee) {
issueRepository.updateAssignee(id, assignee);
}
@Transactional
public void bulkUpdateIssues(String appKey, List<Long> ids, String action) {
String status = switch (action) {
case "resolve" -> "resolved";
case "ignore" -> "ignored";
case "reopen" -> "open";
default -> throw new IllegalArgumentException("Unknown action: " + action);
};
issueRepository.bulkUpdateStatus(appKey, ids, status);
}
@Transactional(readOnly = true)
public List<IssueResponse> getFrequencyRankings(String appKey, String from, String to, int limit) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
Pageable pageable = PageRequest.of(0, limit);
List<LogIssueEntity> issues = issueRepository.findTopByFrequency(appKey, fromDate, toDate, pageable);
return issues.stream().map(this::toIssueResponse).toList();
return issueRepository.findTopByFrequency(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
.stream().map(this::toIssueResponse).toList();
}
@Transactional(readOnly = true)
public List<IssueResponse> getRiskRankings(String appKey, String from, String to, int limit) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
Pageable pageable = PageRequest.of(0, limit);
List<LogIssueEntity> issues = issueRepository.findTopByRisk(appKey, fromDate, toDate, pageable);
return issues.stream().map(this::toIssueResponse).toList();
return issueRepository.findTopByRisk(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
.stream().map(this::toIssueResponse).toList();
}
@Transactional(readOnly = true)
public Page<IssueEventResponse> queryEvents(String appKey, String name, String userId,
String from, String to, int page, int size) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : LocalDateTime.now();
if (fromDate == null) fromDate = LocalDateTime.now().minusDays(7);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<LogEventEntity> result;
if (fromDate != null && toDate != null) {
if (name != null && userId != null) {
result = eventRepository.findByAppKeyAndNameAndUserIdAndCreatedAtBetween(
appKey, name, userId, fromDate, toDate, pageable);
result = eventRepository.findByAppKeyAndNameAndUserIdAndCreatedAtBetween(appKey, name, userId, fromDate, toDate, pageable);
} else if (name != null) {
result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween(
appKey, name, fromDate, toDate, pageable);
result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween(appKey, name, fromDate, toDate, pageable);
} else {
result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable);
}
} else {
result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable);
}
return result.map(e -> new IssueEventResponse(
e.getId(), null, e.getAppKey(), e.getUserId(), e.getSessionId(),
e.getName(), null, null, e.getProperties(),
e.getPlatform(), e.getAppVersion(), e.getCreatedAt()
e.getId(), null, null, e.getAppKey(), e.getUserId(), e.getSessionId(),
null, null, e.getName(), null, null, null, e.getProperties(),
null, e.getPlatform(), e.getRelease(), e.getEnvironment(), null, null, e.getCreatedAt()
));
}
@Transactional(readOnly = true)
public FunnelResponse queryFunnel(String appKey, List<String> steps, String from, String to) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
List<Object[]> rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to));
List<Object[]> rawData = eventRepository.findFunnelData(appKey, steps, fromDate, toDate);
// Count unique sessions per step
Map<String, Set<String>> sessionsPerStep = new LinkedHashMap<>();
for (String step : steps) {
sessionsPerStep.put(step, new HashSet<>());
}
for (String step : steps) sessionsPerStep.put(step, new HashSet<>());
for (Object[] row : rawData) {
String sessionId = (String) row[0];
String sid = (String) row[0];
String name = (String) row[1];
if (sessionsPerStep.containsKey(name) && sessionId != null) {
sessionsPerStep.get(name).add(sessionId);
}
if (sessionsPerStep.containsKey(name) && sid != null) sessionsPerStep.get(name).add(sid);
}
List<Long> counts = steps.stream()
.map(step -> (long) sessionsPerStep.get(step).size())
.toList();
long firstCount = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1);
List<Double> rates = counts.stream()
.map(c -> Math.round((double) c / firstCount * 1000.0) / 10.0)
.toList();
List<Long> counts = steps.stream().map(s -> (long) sessionsPerStep.get(s).size()).toList();
long first = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1);
List<Double> rates = counts.stream().map(c -> Math.round((double) c / first * 1000.0) / 10.0).toList();
return new FunnelResponse(steps, counts, rates);
}
@ -267,29 +320,18 @@ public class LogService {
LocalDateTime toDate = parseDate(to);
long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate);
long openIssues = issueRepository.countByAppKeyAndStatus(appKey, "open");
long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, LocalDate.now().atStartOfDay());
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, todayStart);
// Build daily crash trend
List<OverviewResponse.DailyCrashRate> trend = new ArrayList<>();
if (fromDate != null && toDate != null) {
LocalDate current = fromDate.toLocalDate();
LocalDate end = toDate.toLocalDate();
while (!current.isAfter(end)) {
LocalDateTime dayStart = current.atStartOfDay();
LocalDateTime dayEnd = current.plusDays(1).atStartOfDay();
long dayCount = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, dayStart, dayEnd);
trend.add(new OverviewResponse.DailyCrashRate(
current.toString(),
dayCount,
0.0 // crash rate requires total session data, placeholder for now
));
current = current.plusDays(1);
for (LocalDate c = fromDate.toLocalDate(), end = toDate.toLocalDate(); !c.isAfter(end); c = c.plusDays(1)) {
long dayCount = issueRepository.countByAppKeyAndFirstSeenAtBetween(
appKey, c.atStartOfDay(), c.plusDays(1).atStartOfDay());
trend.add(new OverviewResponse.DailyCrashRate(c.toString(), dayCount, 0.0));
}
}
return new OverviewResponse(totalIssues, todayNewIssues, 0, trend);
return new OverviewResponse(totalIssues, todayNewIssues, openIssues, trend);
}
@Transactional
@ -301,22 +343,34 @@ public class LogService {
private IssueResponse toIssueResponse(LogIssueEntity issue) {
return new IssueResponse(
issue.getId(), issue.getAppKey(), issue.getFingerprint(),
issue.getType(), issue.getTitle(),
issue.getFirstSeenAt(), issue.getLastSeenAt(),
issue.getCount(), issue.isResolved(),
issue.getPlatform(), issue.getAppVersion(),
null
issue.getLevel(),
issue.getStatus() != null ? issue.getStatus() : (issue.isResolved() ? "resolved" : "open"),
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
issue.getCount(), issue.getAffectedUsers(), issue.isResolved(),
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null
);
}
private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) {
return new IssueEventResponse(
e.getId(), e.getIssueId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
e.getMessage(), e.getStack(), e.getStackSymbolicated(), e.getMetadata(),
e.getPlatform(), e.getAppVersion(), e.getCreatedAt()
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(),
e.getPlatform(), e.getRelease(), e.getEnvironment(),
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt()
);
}
private String toJson(Object obj) {
if (obj == null) return null;
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
return obj.toString();
}
}
private String truncate(String s, int maxLen) {
if (s == null) return "";
return s.length() <= maxLen ? s : s.substring(0, maxLen);
@ -324,10 +378,7 @@ public class LogService {
private LocalDateTime parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return null;
try {
return LocalDate.parse(dateStr).atStartOfDay();
} catch (Exception e) {
return null;
}
try { return LocalDate.parse(dateStr).atStartOfDay(); }
catch (Exception e) { return null; }
}
}

查看文件

@ -0,0 +1,23 @@
-- BugCollect API v1.1 upgrade
-- log_issue_events: add eventId for idempotency
ALTER TABLE log_issue_events ADD COLUMN event_id VARCHAR(64) NULL;
ALTER TABLE log_issue_events ADD COLUMN exception_type VARCHAR(200) NULL;
ALTER TABLE log_issue_events ADD COLUMN exception_value TEXT NULL;
ALTER TABLE log_issue_events ADD COLUMN breadcrumbs JSON NULL;
ALTER TABLE log_issue_events ADD UNIQUE KEY uk_app_event_id (app_key, event_id);
ALTER TABLE log_issue_events ADD INDEX idx_issue_created (issue_id, created_at);
-- log_issues: add status, affectedUsers, assignee
ALTER TABLE log_issues ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'open';
ALTER TABLE log_issues ADD COLUMN affected_users INT NOT NULL DEFAULT 0;
ALTER TABLE log_issues ADD COLUMN assignee VARCHAR(200) NULL;
-- migrate existing is_resolved
UPDATE log_issues SET status = 'resolved' WHERE is_resolved = 1;
ALTER TABLE log_issues ADD INDEX idx_app_status_last (app_key, status, last_seen_at);
ALTER TABLE log_issues ADD INDEX idx_app_level_last (app_key, level, last_seen_at);
-- log_events: add eventId for idempotency
ALTER TABLE log_events ADD COLUMN event_id VARCHAR(64) NULL;
ALTER TABLE log_events ADD UNIQUE KEY uk_app_event_id (app_key, event_id);
ALTER TABLE log_events ADD INDEX idx_app_key_created (app_key, created_at);