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 SourcemapService sourcemapService; private final ObjectMapper objectMapper; @org.springframework.beans.factory.annotation.Value("${symbolicator.url:http://xuqm-symbolicator:3000}") private String symbolicatorUrl; public LogService(LogIssueRepository issueRepository, LogIssueEventRepository issueEventRepository, LogIssueUserRepository issueUserRepository, LogEventRepository eventRepository, WebhookService webhookService, SourcemapService sourcemapService, ObjectMapper objectMapper) { this.issueRepository = issueRepository; this.issueEventRepository = issueEventRepository; this.issueUserRepository = issueUserRepository; this.eventRepository = eventRepository; this.webhookService = webhookService; this.sourcemapService = sourcemapService; 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.setBuildId(item.buildId()); 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); eventEntity = issueEventRepository.save(eventEntity); if (isNew) triggerWebhookAsync(issue); triggerSymbolicationAsync(eventEntity.getId(), item.appKey(), item.platform(), item.release(), item.buildId()); } @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 eventId, String appKey, String platform, String release, String buildId) { log.debug("Symbolication triggered eventId={} appKey={} platform={} release={} buildId={}", eventId, appKey, platform, release, buildId); // 查找 sourcemap 文件:精确 buildId 匹配,兜底取最新版本 String storageKey = sourcemapService.findSourceMap(appKey, platform, release, buildId, "index"); if (storageKey == null) { log.debug("No sourcemap found for appKey={} platform={} release={} buildId={}", appKey, platform, release, buildId); return; } // 查找事件(用 eventId 精确定位,而非 issueId) LogIssueEventEntity event = issueEventRepository.findById(eventId).orElse(null); 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); log.info("Symbolicated eventId={} platform={}", eventId, platform); } } catch (Exception e) { log.error("Symbolication failed for eventId={}: {}", eventId, e.getMessage(), e); } } 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 request = java.util.Map.of( "platform", platform, "stacktrace", stacktrace, "mappingFile", sourcemapContent ); org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); org.springframework.http.HttpEntity> 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; } @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.getType() != null ? issue.getType() : 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 reopenIssue(Long id) { LogIssueEntity issue = issueRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id)); issue.setStatus("open"); 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, e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(), null, null, null, parseJsonField(e.getDevice()), e.getPlatform(), e.getRelease(), e.getBuildId(), e.getEnvironment(), e.getSdkName(), e.getSdkVersion(), 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()); // 计算影响用户数(所有 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; 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)); } } // 获取高频错误 Top 5 List 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 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 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 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 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 dailyRaw = issueRepository.countDailyByLevel(appKey, fromDate, toDate); Map 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())); } @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.getType() != null ? issue.getType() : 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.getBuildId(), 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; } } }