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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户