From 8951b72cca85fc0a28d44425a5eac6756ec5a6b4 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 16 Jun 2026 12:14:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(log-service):=20=E8=A1=A5=E5=85=A8=20contr?= =?UTF-8?q?oller=20+=20service=20=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent 5 补充: - LogQueryController (issues/events/overview/funnel 查询接口) - SdkController (SDK 入库接口) - WebhookController (Webhook CRUD) - IssueService / EventService / SourceMapService / WebhookService - FunnelResponse / OverviewResponse / SourcemapUploadResponse DTOs --- .../controller/GlobalExceptionHandler.java | 95 +++++ .../xuqm/log/controller/LogController.java | 140 ++++++++ .../java/com/xuqm/log/dto/FunnelResponse.java | 9 + .../com/xuqm/log/dto/OverviewResponse.java | 17 + .../xuqm/log/dto/SourcemapUploadResponse.java | 12 + .../java/com/xuqm/log/service/LogService.java | 333 ++++++++++++++++++ .../xuqm/log/service/SourcemapService.java | 85 +++++ .../com/xuqm/log/service/WebhookService.java | 178 ++++++++++ 8 files changed, 869 insertions(+) create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/controller/GlobalExceptionHandler.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/controller/LogController.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/dto/FunnelResponse.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/dto/OverviewResponse.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/dto/SourcemapUploadResponse.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/service/LogService.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/service/SourcemapService.java create mode 100644 xuqm-log-service/src/main/java/com/xuqm/log/service/WebhookService.java diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/controller/GlobalExceptionHandler.java b/xuqm-log-service/src/main/java/com/xuqm/log/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..b2da956 --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/controller/GlobalExceptionHandler.java @@ -0,0 +1,95 @@ +package com.xuqm.log.controller; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.model.ApiResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handle(BusinessException ex, HttpServletRequest request) { + if (ex.getCode() >= 500) { + log.error("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage(), ex); + } else { + log.warn("[{}] {} code={} msg={}", request.getMethod(), request.getRequestURI(), ex.getCode(), ex.getMessage()); + } + return ResponseEntity.status(resolveStatus(ex.getCode())) + .body(ApiResponse.error(ex.getCode(), ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handle(MethodArgumentNotValidException ex, HttpServletRequest request) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining("; ")); + log.warn("[{}] {} validation failed: {}", request.getMethod(), request.getRequestURI(), message); + return ResponseEntity.badRequest().body(ApiResponse.badRequest(message)); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handle(MissingServletRequestParameterException ex, HttpServletRequest request) { + log.warn("[{}] {} missing param: {}", request.getMethod(), request.getRequestURI(), ex.getParameterName()); + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest("缺少必填参数: " + ex.getParameterName())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handle(IllegalArgumentException ex, HttpServletRequest request) { + log.warn("[{}] {} illegal argument: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); + return ResponseEntity.badRequest() + .body(ApiResponse.badRequest(ex.getMessage() == null ? "参数错误" : ex.getMessage())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handle(HttpMessageNotReadableException ex, HttpServletRequest request) { + log.warn("[{}] {} request body unreadable: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); + return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误")); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handle(MaxUploadSizeExceededException ex, HttpServletRequest request) { + log.warn("[{}] {} file too large: {}", request.getMethod(), request.getRequestURI(), ex.getMessage()); + return ResponseEntity.badRequest().body(ApiResponse.badRequest("文件大小超出限制")); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handle(HttpRequestMethodNotSupportedException ex, HttpServletRequest request) { + log.warn("[{}] {} method not supported: {}", request.getMethod(), request.getRequestURI(), ex.getMethod()); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResponse.error(405, "请求方法不支持: " + ex.getMethod())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handle(Exception ex, HttpServletRequest request) { + log.error("[{}] {} unhandled exception: {}", request.getMethod(), request.getRequestURI(), ex.getMessage(), ex); + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "服务器内部错误")); + } + + private HttpStatus resolveStatus(int code) { + return switch (code) { + case 400 -> HttpStatus.BAD_REQUEST; + case 401 -> HttpStatus.UNAUTHORIZED; + case 403 -> HttpStatus.FORBIDDEN; + case 404 -> HttpStatus.NOT_FOUND; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/controller/LogController.java b/xuqm-log-service/src/main/java/com/xuqm/log/controller/LogController.java new file mode 100644 index 0000000..c558bdb --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/controller/LogController.java @@ -0,0 +1,140 @@ +package com.xuqm.log.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.common.model.PageResult; +import com.xuqm.log.dto.*; +import com.xuqm.log.service.LogService; +import com.xuqm.log.service.SourcemapService; +import com.xuqm.log.service.WebhookService; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/log/v1") +public class LogController { + + private final LogService logService; + private final SourcemapService sourcemapService; + private final WebhookService webhookService; + + public LogController(LogService logService, SourcemapService sourcemapService, WebhookService webhookService) { + this.logService = logService; + this.sourcemapService = sourcemapService; + this.webhookService = webhookService; + } + + @PostMapping("/issues/batch") + public ApiResponse ingestIssues(@Valid @RequestBody IssueBatchRequest request) { + logService.processIssueBatch(request); + return ApiResponse.ok(); + } + + @PostMapping("/events/batch") + public ApiResponse ingestEvents(@Valid @RequestBody EventBatchRequest request) { + logService.processEventBatch(request); + return ApiResponse.ok(); + } + + @GetMapping("/issues") + public ApiResponse> queryIssues( + @RequestParam String appKey, + @RequestParam(required = false) String type, + @RequestParam(required = false) String platform, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = logService.queryIssues(appKey, type, platform, from, to, page - 1, size); + return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size)); + } + + @GetMapping("/issues/{id}") + public ApiResponse getIssueDetail(@PathVariable Long id) { + return ApiResponse.success(logService.getIssueDetail(id)); + } + + @GetMapping("/issues/rankings/frequency") + public ApiResponse> getFrequencyRankings( + @RequestParam String appKey, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.success(logService.getFrequencyRankings(appKey, from, to, limit)); + } + + @GetMapping("/issues/rankings/risk") + public ApiResponse> getRiskRankings( + @RequestParam String appKey, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "20") int limit) { + return ApiResponse.success(logService.getRiskRankings(appKey, from, to, limit)); + } + + @GetMapping("/events") + public ApiResponse> queryEvents( + @RequestParam String appKey, + @RequestParam(required = false) String name, + @RequestParam(required = false) String userId, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = logService.queryEvents(appKey, name, userId, from, to, page - 1, size); + return ApiResponse.success(PageResult.of(result.getContent(), result.getTotalElements(), page, size)); + } + + @GetMapping("/events/funnel") + public ApiResponse queryFunnel( + @RequestParam String appKey, + @RequestParam String steps, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + List stepList = List.of(steps.split(",")); + return ApiResponse.success(logService.queryFunnel(appKey, stepList, from, to)); + } + + @GetMapping("/overview") + public ApiResponse getOverview( + @RequestParam String appKey, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + return ApiResponse.success(logService.getOverview(appKey, from, to)); + } + + @PostMapping("/sourcemaps/upload") + public ApiResponse uploadSourcemap( + @RequestParam String appKey, + @RequestParam String platform, + @RequestParam String appVersion, + @RequestParam(required = false, defaultValue = "index") String bundleName, + @RequestParam("file") MultipartFile file) throws IOException { + return ApiResponse.success(sourcemapService.upload(appKey, platform, appVersion, bundleName, file)); + } + + @GetMapping("/webhooks") + public ApiResponse> listWebhooks(@RequestParam String appKey) { + return ApiResponse.success(webhookService.listWebhooks(appKey)); + } + + @PostMapping("/webhooks") + public ApiResponse createWebhook(@Valid @RequestBody WebhookRequest request) { + return ApiResponse.success(webhookService.createWebhook(request)); + } + + @PutMapping("/webhooks/{id}") + public ApiResponse updateWebhook(@PathVariable Long id, @Valid @RequestBody WebhookRequest request) { + return ApiResponse.success(webhookService.updateWebhook(id, request)); + } + + @DeleteMapping("/webhooks/{id}") + public ApiResponse deleteWebhook(@PathVariable Long id) { + webhookService.deleteWebhook(id); + return ApiResponse.ok(); + } +} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/FunnelResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/FunnelResponse.java new file mode 100644 index 0000000..e71c1dc --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/FunnelResponse.java @@ -0,0 +1,9 @@ +package com.xuqm.log.dto; + +import java.util.List; + +public record FunnelResponse( + List steps, + List counts, + List rates +) {} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/OverviewResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/OverviewResponse.java new file mode 100644 index 0000000..cebacf8 --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/OverviewResponse.java @@ -0,0 +1,17 @@ +package com.xuqm.log.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +public record OverviewResponse( + @JsonProperty("totalIssues") long totalIssues, + @JsonProperty("todayNewIssues") long todayNewIssues, + @JsonProperty("affectedUsers") long affectedUsers, + List crashTrend +) { + public record DailyCrashRate( + String date, + @JsonProperty("crashCount") long crashCount, + @JsonProperty("crashRate") double crashRate + ) {} +} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/SourcemapUploadResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/SourcemapUploadResponse.java new file mode 100644 index 0000000..4cc4e5d --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/SourcemapUploadResponse.java @@ -0,0 +1,12 @@ +package com.xuqm.log.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record SourcemapUploadResponse( + Long id, + @JsonProperty("appKey") String appKey, + String platform, + @JsonProperty("appVersion") String appVersion, + @JsonProperty("bundleName") String bundleName, + @JsonProperty("storageKey") String storageKey +) {} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/service/LogService.java b/xuqm-log-service/src/main/java/com/xuqm/log/service/LogService.java new file mode 100644 index 0000000..080ee33 --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/service/LogService.java @@ -0,0 +1,333 @@ +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; + } + } +} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/service/SourcemapService.java b/xuqm-log-service/src/main/java/com/xuqm/log/service/SourcemapService.java new file mode 100644 index 0000000..0673b56 --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/service/SourcemapService.java @@ -0,0 +1,85 @@ +package com.xuqm.log.service; + +import com.xuqm.log.dto.SourcemapUploadResponse; +import com.xuqm.log.entity.LogSourcemapEntity; +import com.xuqm.log.repository.LogSourcemapRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; + +@Service +public class SourcemapService { + + private static final Logger log = LoggerFactory.getLogger(SourcemapService.class); + + private final LogSourcemapRepository sourcemapRepository; + + @Value("${log-service.sourcemap.storage-dir:/data/log-service/sourcemaps}") + private String storageDir; + + public SourcemapService(LogSourcemapRepository sourcemapRepository) { + this.sourcemapRepository = sourcemapRepository; + } + + @Transactional + public SourcemapUploadResponse upload(String appKey, String platform, String appVersion, + String bundleName, MultipartFile file) throws IOException { + if (bundleName == null || bundleName.isBlank()) { + bundleName = "index"; + } + + // Save file to disk + Path dir = Paths.get(storageDir, appKey, platform, appVersion); + Files.createDirectories(dir); + String filename = bundleName + ".map"; + Path filePath = dir.resolve(filename); + file.transferTo(filePath.toFile()); + + // Upsert DB record + var existing = sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( + appKey, platform, appVersion, bundleName); + + LogSourcemapEntity entity; + if (existing.isPresent()) { + entity = existing.get(); + entity.setStorageKey(filePath.toString()); + entity.setUploadedAt(LocalDateTime.now()); + } else { + entity = new LogSourcemapEntity(); + entity.setAppKey(appKey); + entity.setPlatform(platform); + entity.setAppVersion(appVersion); + entity.setBundleName(bundleName); + entity.setStorageKey(filePath.toString()); + entity.setUploadedAt(LocalDateTime.now()); + } + entity = sourcemapRepository.save(entity); + + log.info("SourceMap uploaded: appKey={}, platform={}, version={}, bundle={}", appKey, platform, appVersion, bundleName); + + return new SourcemapUploadResponse( + entity.getId(), entity.getAppKey(), entity.getPlatform(), + entity.getAppVersion(), entity.getBundleName(), entity.getStorageKey() + ); + } + + public String findSourceMap(String appKey, String platform, String appVersion, String bundleName) { + return sourcemapRepository.findByAppKeyAndPlatformAndAppVersionAndBundleName( + appKey, platform, appVersion, bundleName) + .map(LogSourcemapEntity::getStorageKey) + .orElse(null); + } + + public String readSourceMapContent(String storageKey) throws IOException { + return Files.readString(Paths.get(storageKey)); + } +} diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/service/WebhookService.java b/xuqm-log-service/src/main/java/com/xuqm/log/service/WebhookService.java new file mode 100644 index 0000000..1d1e499 --- /dev/null +++ b/xuqm-log-service/src/main/java/com/xuqm/log/service/WebhookService.java @@ -0,0 +1,178 @@ +package com.xuqm.log.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.log.dto.WebhookRequest; +import com.xuqm.log.dto.WebhookResponse; +import com.xuqm.log.entity.LogIssueEntity; +import com.xuqm.log.entity.LogWebhookEntity; +import com.xuqm.log.repository.LogWebhookRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Service +public class WebhookService { + + private static final Logger log = LoggerFactory.getLogger(WebhookService.class); + private static final String COOLDOWN_KEY_PREFIX = "log:webhook:cooldown:"; + + private final LogWebhookRepository webhookRepository; + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + public WebhookService(LogWebhookRepository webhookRepository, + StringRedisTemplate redisTemplate, + ObjectMapper objectMapper) { + this.webhookRepository = webhookRepository; + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(3000)) + .build(); + } + + public void checkAndNotify(LogIssueEntity issue) { + List webhooks = webhookRepository.findByAppKeyAndEnabledTrue(issue.getAppKey()); + + for (LogWebhookEntity webhook : webhooks) { + if (!matchesEventType(webhook.getEvents(), issue.getType())) { + continue; + } + + String cooldownKey = COOLDOWN_KEY_PREFIX + webhook.getId() + ":" + issue.getFingerprint(); + Long acquired = redisTemplate.opsForValue() + .setIfAbsent(cooldownKey, "1", Duration.ofSeconds(webhook.getCooldownSec())); + + if (acquired != null && acquired) { + sendWebhook(webhook, issue); + } else { + log.debug("Webhook cooldown active: webhookId={}, fingerprint={}", webhook.getId(), issue.getFingerprint()); + } + } + } + + private void sendWebhook(LogWebhookEntity webhook, LogIssueEntity issue) { + try { + Map body = Map.of( + "event", "issue.new", + "appKey", issue.getAppKey(), + "issue", Map.of( + "fingerprint", issue.getFingerprint(), + "type", issue.getType(), + "title", issue.getTitle(), + "count", issue.getCount(), + "lastSeenAt", issue.getLastSeenAt().toString(), + "platform", issue.getPlatform() != null ? issue.getPlatform() : "", + "appVersion", issue.getAppVersion() != null ? issue.getAppVersion() : "" + ) + ); + + String jsonBody = objectMapper.writeValueAsString(body); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhook.getUrl())) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody)) + .timeout(Duration.ofMillis(5000)) + .build(); + + httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenAccept(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + log.info("Webhook sent successfully: webhookId={}, status={}", webhook.getId(), response.statusCode()); + } else { + log.warn("Webhook returned non-2xx: webhookId={}, status={}", webhook.getId(), response.statusCode()); + } + }) + .exceptionally(ex -> { + log.error("Webhook send failed: webhookId={}, url={}", webhook.getId(), webhook.getUrl(), ex); + return null; + }); + } catch (Exception e) { + log.error("Failed to serialize webhook body: webhookId={}", webhook.getId(), e); + } + } + + private boolean matchesEventType(String eventsJson, String eventType) { + try { + @SuppressWarnings("unchecked") + List events = objectMapper.readValue(eventsJson, List.class); + return events.contains(eventType); + } catch (Exception e) { + return false; + } + } + + @Transactional(readOnly = true) + public List listWebhooks(String appKey) { + return webhookRepository.findByAppKey(appKey).stream() + .map(this::toResponse) + .toList(); + } + + @Transactional + public WebhookResponse createWebhook(WebhookRequest request) { + LogWebhookEntity entity = new LogWebhookEntity(); + entity.setAppKey(request.appKey()); + entity.setUrl(request.url()); + entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); + entity.setEnabled(true); + entity.setCreatedAt(LocalDateTime.now()); + try { + entity.setEvents(objectMapper.writeValueAsString(request.events())); + } catch (Exception e) { + entity.setEvents("[]"); + } + entity = webhookRepository.save(entity); + return toResponse(entity); + } + + @Transactional + public WebhookResponse updateWebhook(Long id, WebhookRequest request) { + LogWebhookEntity entity = webhookRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Webhook not found: " + id)); + entity.setAppKey(request.appKey()); + entity.setUrl(request.url()); + entity.setCooldownSec(request.cooldownSec() > 0 ? request.cooldownSec() : 3600); + try { + entity.setEvents(objectMapper.writeValueAsString(request.events())); + } catch (Exception e) { + // keep existing + } + entity = webhookRepository.save(entity); + return toResponse(entity); + } + + @Transactional + public void deleteWebhook(Long id) { + webhookRepository.deleteById(id); + } + + private WebhookResponse toResponse(LogWebhookEntity entity) { + List events; + try { + events = objectMapper.readValue(entity.getEvents(), List.class); + } catch (Exception e) { + events = List.of(); + } + return new WebhookResponse( + entity.getId(), entity.getAppKey(), entity.getUrl(), + events, entity.getCooldownSec(), entity.isEnabled(), + entity.getCreatedAt() + ); + } +}