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 LogIssueUserRepository issueUserRepository; private final LogEventRepository eventRepository; private final WebhookService webhookService; private final ObjectMapper objectMapper; public LogService(LogIssueRepository issueRepository, LogIssueEventRepository issueEventRepository, LogIssueUserRepository issueUserRepository, LogEventRepository eventRepository, WebhookService webhookService, ObjectMapper objectMapper) { this.issueRepository = issueRepository; this.issueEventRepository = issueEventRepository; this.issueUserRepository = issueUserRepository; 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 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 && !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); } 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 queryIssues(String appKey, String level, String platform, String status, String q, String assignee, String environment, String from, String to, int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")); Page result = issueRepository.findByFilters( appKey, level, platform, status, q, assignee, environment, 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)); Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); List events = issueEventRepository .findByIssueIdOrderByCreatedAtDesc(issue.getId(), pageable) .getContent() .stream() .map(this::toIssueEventResponse) .toList(); return new IssueResponse( issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getLevel(), issue.getStatus() != null ? issue.getStatus() : "open", issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), issue.getCount(), issue.getAffectedUsers(), issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events ); } @Transactional(readOnly = true) public Page 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 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 rows = issueEventRepository.findDailyCountsByIssueId( issueId, fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay()); Map 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 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 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 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 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 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 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 steps, String from, String to) { List rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to)); Map> 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 counts = steps.stream().map(s -> (long) sessionsPerStep.get(s).size()).toList(); long first = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1); List 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 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() : "open", issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), issue.getCount(), issue.getAffectedUsers(), issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null ); } private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) { IssueEventResponse.ExceptionInfo exception = new IssueEventResponse.ExceptionInfo( e.getExceptionType(), e.getExceptionValue() != null ? e.getExceptionValue() : e.getMessage(), e.getStack(), e.getStackSymbolicated() ); return new IssueEventResponse( e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(), exception, parseJsonField(e.getBreadcrumbs()), parseJsonField(e.getTags()), parseJsonField(e.getDevice()), e.getPlatform(), e.getRelease(), e.getEnvironment(), e.getSdkName(), e.getSdkVersion(), e.getCreatedAt() ); } private Object parseJsonField(String json) { if (json == null || json.isBlank()) return null; try { return objectMapper.readValue(json, Object.class); } catch (Exception ex) { return json; } } 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; } } }