XuqmGroup-Server/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.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;
}
}
}