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>
这个提交包含在:
父节点
92f2c7c88b
当前提交
198dc7f960
文件差异内容过多而无法显示
加载差异
@ -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,133 +187,151 @@ 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);
|
||||
} else if (name != null) {
|
||||
result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween(
|
||||
appKey, name, fromDate, toDate, pageable);
|
||||
} else {
|
||||
result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable);
|
||||
}
|
||||
if (name != null && userId != null) {
|
||||
result = eventRepository.findByAppKeyAndNameAndUserIdAndCreatedAtBetween(appKey, name, userId, fromDate, toDate, pageable);
|
||||
} else if (name != null) {
|
||||
result = eventRepository.findByAppKeyAndNameAndCreatedAtBetween(appKey, name, 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);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public OverviewResponse getOverview(String appKey, String from, String to) {
|
||||
LocalDateTime fromDate = parseDate(from);
|
||||
LocalDateTime toDate = parseDate(to);
|
||||
LocalDateTime toDate = parseDate(to);
|
||||
|
||||
long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate);
|
||||
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);
|
||||
正在加载...
在新工单中引用
屏蔽一个用户