2026-06-16 17:39:13 +08:00
|
|
|
|
package com.xuqm.bugcollect.service;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
|
|
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
2026-06-16 17:39:13 +08:00
|
|
|
|
import com.xuqm.bugcollect.dto.*;
|
|
|
|
|
|
import com.xuqm.bugcollect.entity.*;
|
|
|
|
|
|
import com.xuqm.bugcollect.repository.*;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
|
import org.springframework.data.domain.Page;
|
|
|
|
|
|
import org.springframework.data.domain.PageRequest;
|
|
|
|
|
|
import org.springframework.data.domain.Pageable;
|
|
|
|
|
|
import org.springframework.data.domain.Sort;
|
|
|
|
|
|
import org.springframework.scheduling.annotation.Async;
|
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
|
|
|
|
|
|
import java.time.Instant;
|
|
|
|
|
|
import java.time.LocalDate;
|
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
|
import java.time.ZoneOffset;
|
|
|
|
|
|
import java.util.*;
|
|
|
|
|
|
|
|
|
|
|
|
@Service
|
|
|
|
|
|
public class LogService {
|
|
|
|
|
|
|
|
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(LogService.class);
|
|
|
|
|
|
|
|
|
|
|
|
private final LogIssueRepository issueRepository;
|
|
|
|
|
|
private final LogIssueEventRepository issueEventRepository;
|
2026-06-18 09:26:12 +08:00
|
|
|
|
private final LogIssueUserRepository issueUserRepository;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
private final LogEventRepository eventRepository;
|
|
|
|
|
|
private final WebhookService webhookService;
|
2026-06-19 02:50:03 +08:00
|
|
|
|
private final SourcemapService sourcemapService;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
2026-06-19 02:50:03 +08:00
|
|
|
|
@org.springframework.beans.factory.annotation.Value("${symbolicator.url:http://xuqm-symbolicator:3000}")
|
|
|
|
|
|
private String symbolicatorUrl;
|
|
|
|
|
|
|
2026-06-16 12:14:53 +08:00
|
|
|
|
public LogService(LogIssueRepository issueRepository,
|
|
|
|
|
|
LogIssueEventRepository issueEventRepository,
|
2026-06-18 09:26:12 +08:00
|
|
|
|
LogIssueUserRepository issueUserRepository,
|
2026-06-16 12:14:53 +08:00
|
|
|
|
LogEventRepository eventRepository,
|
|
|
|
|
|
WebhookService webhookService,
|
2026-06-19 02:50:03 +08:00
|
|
|
|
SourcemapService sourcemapService,
|
2026-06-16 12:14:53 +08:00
|
|
|
|
ObjectMapper objectMapper) {
|
|
|
|
|
|
this.issueRepository = issueRepository;
|
|
|
|
|
|
this.issueEventRepository = issueEventRepository;
|
2026-06-18 09:26:12 +08:00
|
|
|
|
this.issueUserRepository = issueUserRepository;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
this.eventRepository = eventRepository;
|
|
|
|
|
|
this.webhookService = webhookService;
|
2026-06-19 02:50:03 +08:00
|
|
|
|
this.sourcemapService = sourcemapService;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
this.objectMapper = objectMapper;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public void processIssueBatch(IssueBatchRequest request) {
|
|
|
|
|
|
for (IssueBatchRequest.IssueEventItem item : request.events()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
processSingleIssue(item);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
log.error("Failed to process issue event: fingerprint={}", item.fingerprint(), e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void processSingleIssue(IssueBatchRequest.IssueEventItem item) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
if (item.eventId() != null && !item.eventId().isBlank()
|
|
|
|
|
|
&& issueEventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
|
|
|
|
|
|
log.debug("Skipping duplicate eventId={} appKey={}", item.eventId(), item.appKey());
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 12:14:53 +08:00
|
|
|
|
LocalDateTime now = LocalDateTime.now();
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
|
|
|
|
|
Optional<LogIssueEntity> existing = issueRepository.findByAppKeyAndFingerprint(item.appKey(), item.fingerprint());
|
|
|
|
|
|
|
|
|
|
|
|
LogIssueEntity issue;
|
2026-06-17 15:30:05 +08:00
|
|
|
|
boolean isNew = false;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
if (existing.isPresent()) {
|
|
|
|
|
|
issue = existing.get();
|
|
|
|
|
|
issue.setLastSeenAt(now);
|
|
|
|
|
|
issue.setCount(issue.getCount() + 1);
|
2026-06-18 09:26:12 +08:00
|
|
|
|
if (userId != null && !issueUserRepository.existsByIssueIdAndUserId(issue.getId(), userId)) {
|
|
|
|
|
|
LogIssueUserEntity iu = new LogIssueUserEntity();
|
|
|
|
|
|
iu.setIssueId(issue.getId());
|
|
|
|
|
|
iu.setUserId(userId);
|
|
|
|
|
|
iu.setFirstSeenAt(now);
|
|
|
|
|
|
issueUserRepository.save(iu);
|
|
|
|
|
|
issue.setAffectedUsers(issue.getAffectedUsers() + 1);
|
|
|
|
|
|
}
|
2026-06-17 15:30:05 +08:00
|
|
|
|
if (!"open".equals(issue.getStatus())) issue.setStatus("open");
|
|
|
|
|
|
issueRepository.save(issue);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
issue = new LogIssueEntity();
|
|
|
|
|
|
issue.setAppKey(item.appKey());
|
|
|
|
|
|
issue.setFingerprint(item.fingerprint());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.setLevel(item.level());
|
|
|
|
|
|
issue.setType(item.level()); // backward compat column
|
|
|
|
|
|
issue.setStatus("open");
|
|
|
|
|
|
issue.setTitle(title);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
issue.setFirstSeenAt(eventTime);
|
|
|
|
|
|
issue.setLastSeenAt(now);
|
|
|
|
|
|
issue.setCount(1);
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.setAffectedUsers(userId != null ? 1 : 0);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
issue.setPlatform(item.platform());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.setRelease(item.release());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
issue = issueRepository.save(issue);
|
2026-06-17 15:30:05 +08:00
|
|
|
|
isNew = true;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
LogIssueEventEntity eventEntity = new LogIssueEventEntity();
|
|
|
|
|
|
eventEntity.setIssueId(issue.getId());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
eventEntity.setEventId(item.eventId());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
eventEntity.setAppKey(item.appKey());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
eventEntity.setLevel(item.level());
|
|
|
|
|
|
eventEntity.setUserId(userId);
|
|
|
|
|
|
eventEntity.setExceptionType(exType);
|
|
|
|
|
|
eventEntity.setExceptionValue(exValue);
|
|
|
|
|
|
eventEntity.setMessage(exValue);
|
|
|
|
|
|
eventEntity.setStack(stack);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
eventEntity.setPlatform(item.platform());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
eventEntity.setRelease(item.release());
|
2026-06-22 16:47:34 +08:00
|
|
|
|
eventEntity.setBuildId(item.buildId());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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()));
|
|
|
|
|
|
}
|
2026-06-16 12:14:53 +08:00
|
|
|
|
eventEntity.setCreatedAt(eventTime);
|
2026-06-22 16:47:34 +08:00
|
|
|
|
eventEntity = issueEventRepository.save(eventEntity);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
if (isNew) triggerWebhookAsync(issue);
|
2026-06-22 16:47:34 +08:00
|
|
|
|
triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), item.buildId());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Async
|
|
|
|
|
|
void triggerWebhookAsync(LogIssueEntity issue) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
webhookService.checkAndNotify(issue);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
log.error("Webhook trigger failed for issue={}", issue.getId(), e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Async
|
2026-06-22 16:47:34 +08:00
|
|
|
|
void triggerSymbolicationAsync(Long eventId, String appKey, String platform, String release, String buildId) {
|
|
|
|
|
|
log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}",
|
|
|
|
|
|
eventId, appKey, platform, release, buildId);
|
2026-06-19 02:50:03 +08:00
|
|
|
|
|
2026-06-22 16:47:34 +08:00
|
|
|
|
// 查找 sourcemap 文件:精确 buildId 匹配,兜底取最新版本
|
|
|
|
|
|
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index");
|
2026-06-19 02:50:03 +08:00
|
|
|
|
if (storageKey == null) {
|
2026-06-22 16:47:34 +08:00
|
|
|
|
log.debug("No sourcemap found for appKey={} platform={} release={} buildId={}", appKey, platform, release, buildId);
|
2026-06-19 02:50:03 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 16:47:34 +08:00
|
|
|
|
// 查找事件(用 eventId 精确定位,而非 issueId)
|
|
|
|
|
|
LogIssueEventEntity event = issueEventRepository.findById(eventId).orElse(null);
|
2026-06-19 02:50:03 +08:00
|
|
|
|
if (event == null || event.getStack() == null || event.getStack().isBlank()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 读取 sourcemap 内容
|
|
|
|
|
|
String sourcemapContent = sourcemapService.readSourceMapContent(storageKey);
|
|
|
|
|
|
if (sourcemapContent == null) {
|
|
|
|
|
|
log.warn("Failed to read sourcemap content for storageKey={}", storageKey);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 确定平台类型
|
|
|
|
|
|
String symbolicatePlatform = mapPlatform(platform);
|
|
|
|
|
|
|
|
|
|
|
|
// 调用符号化服务
|
|
|
|
|
|
String symbolicated = callSymbolicator(symbolicatePlatform, event.getStack(), sourcemapContent);
|
|
|
|
|
|
if (symbolicated != null && !symbolicated.equals(event.getStack())) {
|
|
|
|
|
|
event.setStackSymbolicated(symbolicated);
|
|
|
|
|
|
issueEventRepository.save(event);
|
2026-06-22 17:03:33 +08:00
|
|
|
|
log.info("Symbolicated eventId={} platform={}", eventId, platform);
|
2026-06-19 02:50:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
2026-06-22 17:03:33 +08:00
|
|
|
|
log.error("Symbolication failed for eventId={}: {}", eventId, e.getMessage(), e);
|
2026-06-19 02:50:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private String mapPlatform(String platform) {
|
|
|
|
|
|
if (platform == null) return "rn";
|
|
|
|
|
|
return switch (platform.toLowerCase()) {
|
|
|
|
|
|
case "android" -> "android";
|
|
|
|
|
|
case "ios" -> "ios";
|
|
|
|
|
|
case "flutter" -> "flutter";
|
|
|
|
|
|
default -> "rn"; // RN/Electron/Vue3 都使用 JS sourcemap
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private String callSymbolicator(String platform, String stacktrace, String sourcemapContent) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
org.springframework.web.client.RestTemplate restTemplate = new org.springframework.web.client.RestTemplate();
|
|
|
|
|
|
java.util.Map<String, Object> request = java.util.Map.of(
|
|
|
|
|
|
"platform", platform,
|
|
|
|
|
|
"stacktrace", stacktrace,
|
2026-06-22 17:50:47 +08:00
|
|
|
|
"mappingFile", sourcemapContent
|
2026-06-19 02:50:03 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
|
|
|
|
|
|
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
|
|
|
|
|
|
org.springframework.http.HttpEntity<java.util.Map<String, Object>> entity = new org.springframework.http.HttpEntity<>(request, headers);
|
|
|
|
|
|
|
|
|
|
|
|
java.util.Map response = restTemplate.postForObject(
|
|
|
|
|
|
symbolicatorUrl + "/api/symbolicate",
|
|
|
|
|
|
entity,
|
|
|
|
|
|
java.util.Map.class
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (response != null && Boolean.TRUE.equals(response.get("success"))) {
|
|
|
|
|
|
return (String) response.get("symbolicated");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
log.error("Symbolicator service call failed: {}", e.getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public void processEventBatch(EventBatchRequest request) {
|
|
|
|
|
|
for (EventBatchRequest.EventItem item : request.events()) {
|
|
|
|
|
|
try {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
if (item.eventId() != null && !item.eventId().isBlank()
|
|
|
|
|
|
&& eventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-06-16 12:14:53 +08:00
|
|
|
|
LogEventEntity entity = new LogEventEntity();
|
2026-06-17 15:30:05 +08:00
|
|
|
|
entity.setEventId(item.eventId());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
entity.setAppKey(item.appKey());
|
|
|
|
|
|
entity.setName(item.name());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
entity.setUserId(item.user() != null ? item.user().id() : null);
|
|
|
|
|
|
entity.setProperties(item.properties() != null ? toJson(item.properties()) : null);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
entity.setPlatform(item.platform());
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
entity.setCreatedAt(
|
|
|
|
|
|
item.timestamp() > 0
|
|
|
|
|
|
? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime()
|
|
|
|
|
|
: LocalDateTime.now()
|
|
|
|
|
|
);
|
|
|
|
|
|
eventRepository.save(entity);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
log.error("Failed to process event: name={}", item.name(), e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
2026-06-17 15:30:05 +08:00
|
|
|
|
public Page<IssueResponse> queryIssues(String appKey, String level, String platform,
|
2026-06-18 09:26:12 +08:00
|
|
|
|
String status, String q, String assignee, String environment,
|
2026-06-16 12:14:53 +08:00
|
|
|
|
String from, String to, int page, int size) {
|
|
|
|
|
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
|
2026-06-17 15:30:05 +08:00
|
|
|
|
Page<LogIssueEntity> result = issueRepository.findByFilters(
|
2026-06-18 09:26:12 +08:00
|
|
|
|
appKey, level, platform, status, q, assignee, environment, parseDate(from), parseDate(to), pageable);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
return result.map(this::toIssueResponse);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public IssueResponse getIssueDetail(Long id) {
|
|
|
|
|
|
LogIssueEntity issue = issueRepository.findById(id)
|
|
|
|
|
|
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
|
2026-06-18 08:48:48 +08:00
|
|
|
|
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
|
|
|
|
List<IssueEventResponse> events = issueEventRepository
|
|
|
|
|
|
.findByIssueIdOrderByCreatedAtDesc(issue.getId(), pageable)
|
|
|
|
|
|
.getContent()
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.map(this::toIssueEventResponse)
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
return new IssueResponse(
|
|
|
|
|
|
issue.getId(), issue.getAppKey(), issue.getFingerprint(),
|
|
|
|
|
|
issue.getLevel(),
|
2026-06-19 01:35:30 +08:00
|
|
|
|
issue.getType() != null ? issue.getType() : issue.getLevel(),
|
2026-06-18 09:26:12 +08:00
|
|
|
|
issue.getStatus() != null ? issue.getStatus() : "open",
|
2026-06-18 08:48:48 +08:00
|
|
|
|
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
|
2026-06-18 09:26:12 +08:00
|
|
|
|
issue.getCount(), issue.getAffectedUsers(),
|
2026-06-18 08:48:48 +08:00
|
|
|
|
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events
|
|
|
|
|
|
);
|
2026-06-17 15:30:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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);
|
|
|
|
|
|
}
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
@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]))
|
2026-06-16 12:14:53 +08:00
|
|
|
|
.toList();
|
2026-06-17 15:30:05 +08:00
|
|
|
|
return new IssueTrendResponse(issueId, points);
|
|
|
|
|
|
}
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
@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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 10:47:51 +08:00
|
|
|
|
@Transactional
|
|
|
|
|
|
public void reopenIssue(Long id) {
|
|
|
|
|
|
LogIssueEntity issue = issueRepository.findById(id)
|
|
|
|
|
|
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
|
|
|
|
|
|
issue.setStatus("open");
|
|
|
|
|
|
issueRepository.save(issue);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
@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);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public List<IssueResponse> getFrequencyRankings(String appKey, String from, String to, int limit) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
return issueRepository.findTopByFrequency(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
|
|
|
|
|
|
.stream().map(this::toIssueResponse).toList();
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public List<IssueResponse> getRiskRankings(String appKey, String from, String to, int limit) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
return issueRepository.findTopByRisk(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
|
|
|
|
|
|
.stream().map(this::toIssueResponse).toList();
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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);
|
2026-06-17 15:30:05 +08:00
|
|
|
|
LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : LocalDateTime.now();
|
|
|
|
|
|
if (fromDate == null) fromDate = LocalDateTime.now().minusDays(7);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
|
|
|
|
Page<LogEventEntity> result;
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
result = eventRepository.findByAppKeyAndCreatedAtBetween(appKey, fromDate, toDate, pageable);
|
|
|
|
|
|
}
|
|
|
|
|
|
return result.map(e -> new IssueEventResponse(
|
2026-06-18 11:23:24 +08:00
|
|
|
|
e.getId(), null, e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
|
|
|
|
|
|
null, null, null, parseJsonField(e.getDevice()),
|
2026-06-22 18:26:48 +08:00
|
|
|
|
e.getPlatform(), e.getRelease(), e.getBuildId(), e.getEnvironment(),
|
2026-06-18 11:23:24 +08:00
|
|
|
|
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt()
|
2026-06-16 12:14:53 +08:00
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public FunnelResponse queryFunnel(String appKey, List<String> steps, String from, String to) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
List<Object[]> rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to));
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
|
|
|
|
|
Map<String, Set<String>> sessionsPerStep = new LinkedHashMap<>();
|
2026-06-17 15:30:05 +08:00
|
|
|
|
for (String step : steps) sessionsPerStep.put(step, new HashSet<>());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
for (Object[] row : rawData) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
String sid = (String) row[0];
|
2026-06-16 12:14:53 +08:00
|
|
|
|
String name = (String) row[1];
|
2026-06-17 15:30:05 +08:00
|
|
|
|
if (sessionsPerStep.containsKey(name) && sid != null) sessionsPerStep.get(name).add(sid);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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();
|
2026-06-16 12:14:53 +08:00
|
|
|
|
return new FunnelResponse(steps, counts, rates);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public OverviewResponse getOverview(String appKey, String from, String to) {
|
|
|
|
|
|
LocalDateTime fromDate = parseDate(from);
|
2026-06-17 15:30:05 +08:00
|
|
|
|
LocalDateTime toDate = parseDate(to);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate);
|
|
|
|
|
|
long openIssues = issueRepository.countByAppKeyAndStatus(appKey, "open");
|
|
|
|
|
|
long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, LocalDate.now().atStartOfDay());
|
2026-06-16 12:14:53 +08:00
|
|
|
|
|
2026-06-19 01:22:46 +08:00
|
|
|
|
// 计算影响用户数(所有 Issue 的 affectedUsers 之和)
|
|
|
|
|
|
long affectedUsers = issueRepository.sumAffectedUsers(appKey);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算崩溃率(fatal + error 占总数的百分比)
|
|
|
|
|
|
long fatalCount = issueRepository.countByAppKeyAndLevel(appKey, "fatal");
|
|
|
|
|
|
long errorCount = issueRepository.countByAppKeyAndLevel(appKey, "error");
|
|
|
|
|
|
double crashRate = totalIssues > 0 ? ((double)(fatalCount + errorCount) / totalIssues) * 100 : 0;
|
|
|
|
|
|
|
2026-06-16 12:14:53 +08:00
|
|
|
|
List<OverviewResponse.DailyCrashRate> trend = new ArrayList<>();
|
|
|
|
|
|
if (fromDate != null && toDate != null) {
|
2026-06-17 15:30:05 +08:00
|
|
|
|
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));
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-19 01:22:46 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取高频错误 Top 5
|
|
|
|
|
|
List<OverviewResponse.TopIssue> topIssues = issueRepository.findTopByFrequency(appKey, fromDate, toDate, PageRequest.of(0, 5))
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.map(issue -> new OverviewResponse.TopIssue(
|
|
|
|
|
|
issue.getId(),
|
|
|
|
|
|
issue.getTitle(),
|
|
|
|
|
|
issue.getType() != null ? issue.getType() : issue.getLevel(),
|
|
|
|
|
|
issue.getCount()))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
|
|
return new OverviewResponse(totalIssues, todayNewIssues, affectedUsers, crashRate, trend, topIssues);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取统计数据:级别分布、状态分布、平台分布、版本分布、每日趋势(按级别堆叠)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Transactional(readOnly = true)
|
|
|
|
|
|
public StatisticsResponse getStatistics(String appKey, String from, String to) {
|
|
|
|
|
|
LocalDateTime fromDate = parseDate(from);
|
|
|
|
|
|
LocalDateTime toDate = parseDate(to);
|
|
|
|
|
|
|
|
|
|
|
|
// 级别分布
|
|
|
|
|
|
List<StatisticsResponse.DistributionItem> levelDist = issueRepository.countByLevel(appKey, fromDate, toDate)
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.map(row -> new StatisticsResponse.DistributionItem(
|
|
|
|
|
|
row[0] != null ? row[0].toString() : "unknown",
|
|
|
|
|
|
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
|
|
// 状态分布
|
|
|
|
|
|
List<StatisticsResponse.DistributionItem> statusDist = issueRepository.countByStatus(appKey, fromDate, toDate)
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.map(row -> new StatisticsResponse.DistributionItem(
|
|
|
|
|
|
row[0] != null ? row[0].toString() : "unknown",
|
|
|
|
|
|
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
|
|
// 平台分布
|
|
|
|
|
|
List<StatisticsResponse.DistributionItem> platformDist = issueRepository.countByPlatform(appKey, fromDate, toDate)
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.map(row -> new StatisticsResponse.DistributionItem(
|
|
|
|
|
|
row[0] != null ? row[0].toString() : "unknown",
|
|
|
|
|
|
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
|
|
// 版本分布(Top 10)
|
|
|
|
|
|
List<StatisticsResponse.DistributionItem> versionDist = issueRepository.countByVersion(appKey, fromDate, toDate)
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.limit(10)
|
|
|
|
|
|
.map(row -> new StatisticsResponse.DistributionItem(
|
|
|
|
|
|
row[0] != null ? row[0].toString() : "unknown",
|
|
|
|
|
|
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
|
|
|
|
|
|
// 每日趋势(按级别堆叠)
|
|
|
|
|
|
List<Object[]> dailyRaw = issueRepository.countDailyByLevel(appKey, fromDate, toDate);
|
|
|
|
|
|
Map<String, StatisticsResponse.DailyTrendItem> dailyMap = new LinkedHashMap<>();
|
|
|
|
|
|
for (Object[] row : dailyRaw) {
|
|
|
|
|
|
String day = row[0] != null ? row[0].toString() : "";
|
|
|
|
|
|
String level = row[1] != null ? row[1].toString() : "unknown";
|
|
|
|
|
|
long count = row[2] instanceof Number ? ((Number) row[2]).longValue() : 0;
|
|
|
|
|
|
|
|
|
|
|
|
StatisticsResponse.DailyTrendItem existing = dailyMap.get(day);
|
|
|
|
|
|
if (existing == null) {
|
|
|
|
|
|
existing = new StatisticsResponse.DailyTrendItem(day, 0, 0, 0, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
long fatal = existing.fatal() + ("fatal".equals(level) ? count : 0);
|
|
|
|
|
|
long error = existing.error() + ("error".equals(level) ? count : 0);
|
|
|
|
|
|
long warning = existing.warning() + ("warning".equals(level) ? count : 0);
|
|
|
|
|
|
long total = existing.total() + count;
|
|
|
|
|
|
dailyMap.put(day, new StatisticsResponse.DailyTrendItem(day, fatal, error, warning, total));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return new StatisticsResponse(levelDist, statusDist, platformDist, versionDist, new ArrayList<>(dailyMap.values()));
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public void deleteIssueAndEvents(Long issueId) {
|
|
|
|
|
|
issueEventRepository.deleteByIssueId(issueId);
|
|
|
|
|
|
issueRepository.deleteById(issueId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private IssueResponse toIssueResponse(LogIssueEntity issue) {
|
|
|
|
|
|
return new IssueResponse(
|
|
|
|
|
|
issue.getId(), issue.getAppKey(), issue.getFingerprint(),
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.getLevel(),
|
2026-06-19 01:22:46 +08:00
|
|
|
|
issue.getType() != null ? issue.getType() : issue.getLevel(),
|
2026-06-18 09:26:12 +08:00
|
|
|
|
issue.getStatus() != null ? issue.getStatus() : "open",
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
|
2026-06-18 09:26:12 +08:00
|
|
|
|
issue.getCount(), issue.getAffectedUsers(),
|
2026-06-17 15:30:05 +08:00
|
|
|
|
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null
|
2026-06-16 12:14:53 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) {
|
2026-06-18 09:26:12 +08:00
|
|
|
|
IssueEventResponse.ExceptionInfo exception = new IssueEventResponse.ExceptionInfo(
|
|
|
|
|
|
e.getExceptionType(),
|
|
|
|
|
|
e.getExceptionValue() != null ? e.getExceptionValue() : e.getMessage(),
|
|
|
|
|
|
e.getStack(),
|
|
|
|
|
|
e.getStackSymbolicated()
|
|
|
|
|
|
);
|
2026-06-16 12:14:53 +08:00
|
|
|
|
return new IssueEventResponse(
|
2026-06-17 15:30:05 +08:00
|
|
|
|
e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
|
2026-06-18 09:26:12 +08:00
|
|
|
|
exception,
|
|
|
|
|
|
parseJsonField(e.getBreadcrumbs()),
|
|
|
|
|
|
parseJsonField(e.getTags()),
|
|
|
|
|
|
parseJsonField(e.getDevice()),
|
2026-06-22 18:26:48 +08:00
|
|
|
|
e.getPlatform(), e.getRelease(), e.getBuildId(), e.getEnvironment(),
|
2026-06-17 15:30:05 +08:00
|
|
|
|
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt()
|
2026-06-16 12:14:53 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 09:26:12 +08:00
|
|
|
|
private Object parseJsonField(String json) {
|
|
|
|
|
|
if (json == null || json.isBlank()) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return objectMapper.readValue(json, Object.class);
|
|
|
|
|
|
} catch (Exception ex) {
|
|
|
|
|
|
return json;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 15:30:05 +08:00
|
|
|
|
private String toJson(Object obj) {
|
|
|
|
|
|
if (obj == null) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return objectMapper.writeValueAsString(obj);
|
|
|
|
|
|
} catch (JsonProcessingException e) {
|
|
|
|
|
|
return obj.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 12:14:53 +08:00
|
|
|
|
private String truncate(String s, int maxLen) {
|
|
|
|
|
|
if (s == null) return "";
|
|
|
|
|
|
return s.length() <= maxLen ? s : s.substring(0, maxLen);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private LocalDateTime parseDate(String dateStr) {
|
|
|
|
|
|
if (dateStr == null || dateStr.isBlank()) return null;
|
2026-06-17 15:30:05 +08:00
|
|
|
|
try { return LocalDate.parse(dateStr).atStartOfDay(); }
|
|
|
|
|
|
catch (Exception e) { return null; }
|
2026-06-16 12:14:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|