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
这个提交包含在:
XuqmGroup 2026-06-16 12:14:53 +08:00
父节点 936664c9cd
当前提交 8951b72cca
共有 8 个文件被更改,包括 869 次插入0 次删除

查看文件

@ -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()
);
}
}