package com.xuqm.log.service; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.log.dto.*; import com.xuqm.log.entity.*; import com.xuqm.log.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 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 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 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 events = issueEventRepository.findTop20ByIssueIdOrderByCreatedAtDesc(id); List 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 getFrequencyRankings(String appKey, String from, String to, int limit) { LocalDateTime fromDate = parseDate(from); LocalDateTime toDate = parseDate(to); Pageable pageable = PageRequest.of(0, limit); List issues = issueRepository.findTopByFrequency(appKey, fromDate, toDate, pageable); return issues.stream().map(this::toIssueResponse).toList(); } @Transactional(readOnly = true) public List getRiskRankings(String appKey, String from, String to, int limit) { LocalDateTime fromDate = parseDate(from); LocalDateTime toDate = parseDate(to); Pageable pageable = PageRequest.of(0, limit); List issues = issueRepository.findTopByRisk(appKey, fromDate, toDate, pageable); return issues.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); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); Page 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 steps, String from, String to) { LocalDateTime fromDate = parseDate(from); LocalDateTime toDate = parseDate(to); List rawData = eventRepository.findFunnelData(appKey, steps, fromDate, toDate); // Count unique sessions per step Map> 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 counts = steps.stream() .map(step -> (long) sessionsPerStep.get(step).size()) .toList(); long firstCount = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1); List 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 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; } } }