- 目录/包名/API路径/数据库/容器名全部重命名 - 26个Java文件包名 com.xuqm.log → com.xuqm.bugcollect - API前缀 /log/v1/ → /bugcollect/v1/ - mvn compile 通过 Co-Authored-By: Claude <noreply@anthropic.com>
334 行
14 KiB
Java
334 行
14 KiB
Java
package com.xuqm.bugcollect.service;
|
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.xuqm.bugcollect.dto.*;
|
|
import com.xuqm.bugcollect.entity.*;
|
|
import com.xuqm.bugcollect.repository.*;
|
|
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.time.format.DateTimeFormatter;
|
|
import java.util.*;
|
|
import java.util.stream.Collectors;
|
|
|
|
@Service
|
|
public class LogService {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(LogService.class);
|
|
|
|
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;
|
|
}
|
|
|
|
@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) {
|
|
LocalDateTime now = LocalDateTime.now();
|
|
LocalDateTime eventTime = Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime();
|
|
|
|
Optional<LogIssueEntity> existing = issueRepository.findByAppKeyAndFingerprint(item.appKey(), item.fingerprint());
|
|
|
|
LogIssueEntity issue;
|
|
if (existing.isPresent()) {
|
|
issue = existing.get();
|
|
issueRepository.incrementCount(item.appKey(), item.fingerprint(), now);
|
|
issue.setLastSeenAt(now);
|
|
issue.setCount(issue.getCount() + 1);
|
|
} else {
|
|
issue = new LogIssueEntity();
|
|
issue.setAppKey(item.appKey());
|
|
issue.setFingerprint(item.fingerprint());
|
|
issue.setType(item.type());
|
|
issue.setTitle(truncate(item.message(), 500));
|
|
issue.setFirstSeenAt(eventTime);
|
|
issue.setLastSeenAt(now);
|
|
issue.setCount(1);
|
|
issue.setPlatform(item.platform());
|
|
issue.setAppVersion(item.appVersion());
|
|
issue = issueRepository.save(issue);
|
|
}
|
|
|
|
LogIssueEventEntity eventEntity = new LogIssueEventEntity();
|
|
eventEntity.setIssueId(issue.getId());
|
|
eventEntity.setAppKey(item.appKey());
|
|
eventEntity.setUserId(item.userId());
|
|
eventEntity.setSessionId(item.sessionId());
|
|
eventEntity.setMessage(item.message());
|
|
eventEntity.setStack(item.stack());
|
|
eventEntity.setPlatform(item.platform());
|
|
eventEntity.setAppVersion(item.appVersion());
|
|
eventEntity.setCreatedAt(eventTime);
|
|
issueEventRepository.save(eventEntity);
|
|
|
|
triggerWebhookAsync(issue);
|
|
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.appVersion());
|
|
}
|
|
|
|
@Async
|
|
void triggerWebhookAsync(LogIssueEntity issue) {
|
|
try {
|
|
webhookService.checkAndNotify(issue);
|
|
} catch (Exception e) {
|
|
log.error("Webhook trigger failed for issue={}", issue.getId(), e);
|
|
}
|
|
}
|
|
|
|
@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);
|
|
}
|
|
|
|
@Transactional
|
|
public void processEventBatch(EventBatchRequest request) {
|
|
for (EventBatchRequest.EventItem item : request.events()) {
|
|
try {
|
|
LogEventEntity entity = new LogEventEntity();
|
|
entity.setAppKey(item.appKey());
|
|
entity.setName(item.name());
|
|
entity.setUserId(item.userId());
|
|
entity.setSessionId(item.sessionId());
|
|
entity.setProperties(item.properties());
|
|
entity.setPlatform(item.platform());
|
|
entity.setAppVersion(item.appVersion());
|
|
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)
|
|
public Page<IssueResponse> queryIssues(String appKey, String type, String platform,
|
|
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);
|
|
}
|
|
|
|
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));
|
|
|
|
List<LogIssueEventEntity> events = issueEventRepository.findTop20ByIssueIdOrderByCreatedAtDesc(id);
|
|
List<IssueEventResponse> eventResponses = events.stream()
|
|
.map(this::toIssueEventResponse)
|
|
.toList();
|
|
|
|
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(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();
|
|
}
|
|
|
|
@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();
|
|
}
|
|
|
|
@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);
|
|
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);
|
|
}
|
|
} 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()
|
|
));
|
|
}
|
|
|
|
@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, fromDate, toDate);
|
|
|
|
// Count unique sessions per step
|
|
Map<String, Set<String>> sessionsPerStep = new LinkedHashMap<>();
|
|
for (String step : steps) {
|
|
sessionsPerStep.put(step, new HashSet<>());
|
|
}
|
|
|
|
for (Object[] row : rawData) {
|
|
String sessionId = (String) row[0];
|
|
String name = (String) row[1];
|
|
if (sessionsPerStep.containsKey(name) && sessionId != null) {
|
|
sessionsPerStep.get(name).add(sessionId);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
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);
|
|
|
|
long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
return new OverviewResponse(totalIssues, todayNewIssues, 0, trend);
|
|
}
|
|
|
|
@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(),
|
|
issue.getType(), issue.getTitle(),
|
|
issue.getFirstSeenAt(), issue.getLastSeenAt(),
|
|
issue.getCount(), issue.isResolved(),
|
|
issue.getPlatform(), issue.getAppVersion(),
|
|
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()
|
|
);
|
|
}
|
|
|
|
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;
|
|
try {
|
|
return LocalDate.parse(dateStr).atStartOfDay();
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|