XuqmGroup-Server/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java

385 行
18 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.util.*;
@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 WebhookService webhookService;
private final ObjectMapper objectMapper;
public LogService(LogIssueRepository issueRepository,
LogIssueEventRepository issueEventRepository,
LogEventRepository eventRepository,
WebhookService webhookService,
ObjectMapper objectMapper) {
this.issueRepository = issueRepository;
this.issueEventRepository = issueEventRepository;
this.eventRepository = eventRepository;
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) {
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 = 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();
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.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.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.setLevel(item.level());
eventEntity.setUserId(userId);
eventEntity.setExceptionType(exType);
eventEntity.setExceptionValue(exValue);
eventEntity.setMessage(exValue);
eventEntity.setStack(stack);
eventEntity.setPlatform(item.platform());
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);
if (isNew) triggerWebhookAsync(issue);
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.release());
}
@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 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.user() != null ? item.user().id() : null);
entity.setProperties(item.properties() != null ? toJson(item.properties()) : null);
entity.setPlatform(item.platform());
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()
: 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 level, String platform,
String status, String q,
String from, String to, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
Page<LogIssueEntity> result = issueRepository.findByFilters(
appKey, level, platform, status, q, parseDate(from), parseDate(to), pageable);
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));
return toIssueResponse(issue);
}
@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);
}
@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) {
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) {
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) != 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 (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, 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) {
List<Object[]> rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to));
Map<String, Set<String>> sessionsPerStep = new LinkedHashMap<>();
for (String step : steps) sessionsPerStep.put(step, new HashSet<>());
for (Object[] row : rawData) {
String sid = (String) row[0];
String name = (String) row[1];
if (sessionsPerStep.containsKey(name) && sid != null) sessionsPerStep.get(name).add(sid);
}
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);
long totalIssues = issueRepository.countByAppKeyAndFirstSeenAtBetween(appKey, fromDate, toDate);
long openIssues = issueRepository.countByAppKeyAndStatus(appKey, "open");
long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, LocalDate.now().atStartOfDay());
List<OverviewResponse.DailyCrashRate> trend = new ArrayList<>();
if (fromDate != null && toDate != null) {
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, openIssues, 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.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.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);
}
private LocalDateTime parseDate(String dateStr) {
if (dateStr == null || dateStr.isBlank()) return null;
try { return LocalDate.parse(dateStr).atStartOfDay(); }
catch (Exception e) { return null; }
}
}