feat(log-service): 补全 controller + service 层
Agent 5 补充: - LogQueryController (issues/events/overview/funnel 查询接口) - SdkController (SDK 入库接口) - WebhookController (Webhook CRUD) - IssueService / EventService / SourceMapService / WebhookService - FunnelResponse / OverviewResponse / SourcemapUploadResponse DTOs
这个提交包含在:
父节点
936664c9cd
当前提交
8951b72cca
@ -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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<Void> ingestIssues(@Valid @RequestBody IssueBatchRequest request) {
|
||||
logService.processIssueBatch(request);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/events/batch")
|
||||
public ApiResponse<Void> ingestEvents(@Valid @RequestBody EventBatchRequest request) {
|
||||
logService.processEventBatch(request);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/issues")
|
||||
public ApiResponse<PageResult<IssueResponse>> 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<IssueResponse> 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<IssueResponse> getIssueDetail(@PathVariable Long id) {
|
||||
return ApiResponse.success(logService.getIssueDetail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/issues/rankings/frequency")
|
||||
public ApiResponse<List<IssueResponse>> 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<List<IssueResponse>> 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<PageResult<IssueEventResponse>> 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<IssueEventResponse> 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<FunnelResponse> queryFunnel(
|
||||
@RequestParam String appKey,
|
||||
@RequestParam String steps,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to) {
|
||||
List<String> stepList = List.of(steps.split(","));
|
||||
return ApiResponse.success(logService.queryFunnel(appKey, stepList, from, to));
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
public ApiResponse<OverviewResponse> 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<SourcemapUploadResponse> 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<List<WebhookResponse>> listWebhooks(@RequestParam String appKey) {
|
||||
return ApiResponse.success(webhookService.listWebhooks(appKey));
|
||||
}
|
||||
|
||||
@PostMapping("/webhooks")
|
||||
public ApiResponse<WebhookResponse> createWebhook(@Valid @RequestBody WebhookRequest request) {
|
||||
return ApiResponse.success(webhookService.createWebhook(request));
|
||||
}
|
||||
|
||||
@PutMapping("/webhooks/{id}")
|
||||
public ApiResponse<WebhookResponse> updateWebhook(@PathVariable Long id, @Valid @RequestBody WebhookRequest request) {
|
||||
return ApiResponse.success(webhookService.updateWebhook(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/webhooks/{id}")
|
||||
public ApiResponse<Void> deleteWebhook(@PathVariable Long id) {
|
||||
webhookService.deleteWebhook(id);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.xuqm.log.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record FunnelResponse(
|
||||
List<String> steps,
|
||||
List<Long> counts,
|
||||
List<Double> rates
|
||||
) {}
|
||||
@ -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<DailyCrashRate> crashTrend
|
||||
) {
|
||||
public record DailyCrashRate(
|
||||
String date,
|
||||
@JsonProperty("crashCount") long crashCount,
|
||||
@JsonProperty("crashRate") double crashRate
|
||||
) {}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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<LogWebhookEntity> 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<String, Object> 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<String> events = objectMapper.readValue(eventsJson, List.class);
|
||||
return events.contains(eventType);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<WebhookResponse> 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<String> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户