XuqmGroup-Server/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java

597 行
28 KiB
Java

package com.xuqm.bugcollect.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.bugcollect.dto.*;
import com.xuqm.bugcollect.entity.*;
import com.xuqm.bugcollect.repository.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
@Service
public class LogService {
private static final Logger log = LoggerFactory.getLogger(LogService.class);
private final LogIssueRepository issueRepository;
private final LogIssueEventRepository issueEventRepository;
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
private final LogIssueUserRepository issueUserRepository;
private final LogEventRepository eventRepository;
private final WebhookService webhookService;
private final SourcemapService sourcemapService;
private final ObjectMapper objectMapper;
@org.springframework.beans.factory.annotation.Value("${symbolicator.url:http://xuqm-symbolicator:3000}")
private String symbolicatorUrl;
public LogService(LogIssueRepository issueRepository,
LogIssueEventRepository issueEventRepository,
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
LogIssueUserRepository issueUserRepository,
LogEventRepository eventRepository,
WebhookService webhookService,
SourcemapService sourcemapService,
ObjectMapper objectMapper) {
this.issueRepository = issueRepository;
this.issueEventRepository = issueEventRepository;
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
this.issueUserRepository = issueUserRepository;
this.eventRepository = eventRepository;
this.webhookService = webhookService;
this.sourcemapService = sourcemapService;
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) {
if (item.eventId() != null && !item.eventId().isBlank()
&& issueEventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
log.debug("Skipping duplicate eventId={} appKey={}", item.eventId(), item.appKey());
return;
}
LocalDateTime now = LocalDateTime.now();
LocalDateTime eventTime = item.timestamp() > 0
? Instant.ofEpochMilli(item.timestamp()).atZone(ZoneOffset.UTC).toLocalDateTime()
: now;
String exType = item.exception() != null ? item.exception().type() : "";
String exValue = item.exception() != null ? item.exception().value() : "";
String stack = item.exception() != null ? item.exception().stacktrace() : "";
String title = truncate(exType + (!exValue.isEmpty() ? ": " + exValue : ""), 500);
String userId = item.user() != null ? item.user().id() : null;
Optional<LogIssueEntity> existing = issueRepository.findByAppKeyAndFingerprint(item.appKey(), item.fingerprint());
LogIssueEntity issue;
boolean isNew = false;
if (existing.isPresent()) {
issue = existing.get();
issue.setLastSeenAt(now);
issue.setCount(issue.getCount() + 1);
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
if (userId != null && !issueUserRepository.existsByIssueIdAndUserId(issue.getId(), userId)) {
LogIssueUserEntity iu = new LogIssueUserEntity();
iu.setIssueId(issue.getId());
iu.setUserId(userId);
iu.setFirstSeenAt(now);
issueUserRepository.save(iu);
issue.setAffectedUsers(issue.getAffectedUsers() + 1);
}
if (!"open".equals(issue.getStatus())) issue.setStatus("open");
issueRepository.save(issue);
} else {
issue = new LogIssueEntity();
issue.setAppKey(item.appKey());
issue.setFingerprint(item.fingerprint());
issue.setLevel(item.level());
issue.setType(item.level()); // backward compat column
issue.setStatus("open");
issue.setTitle(title);
issue.setFirstSeenAt(eventTime);
issue.setLastSeenAt(now);
issue.setCount(1);
issue.setAffectedUsers(userId != null ? 1 : 0);
issue.setPlatform(item.platform());
issue.setRelease(item.release());
issue = issueRepository.save(issue);
isNew = true;
}
LogIssueEventEntity eventEntity = new LogIssueEventEntity();
eventEntity.setIssueId(issue.getId());
eventEntity.setEventId(item.eventId());
eventEntity.setAppKey(item.appKey());
eventEntity.setLevel(item.level());
eventEntity.setUserId(userId);
eventEntity.setExceptionType(exType);
eventEntity.setExceptionValue(exValue);
eventEntity.setMessage(exValue);
eventEntity.setStack(stack);
eventEntity.setPlatform(item.platform());
eventEntity.setRelease(item.release());
eventEntity.setEnvironment(item.environment() != null ? item.environment() : "production");
eventEntity.setDevice(item.device() != null ? toJson(item.device()) : null);
eventEntity.setTags(item.tags() != null ? toJson(item.tags()) : null);
if (item.breadcrumbs() != null && !item.breadcrumbs().isEmpty()) {
eventEntity.setBreadcrumbs(toJson(item.breadcrumbs()));
}
eventEntity.setCreatedAt(eventTime);
issueEventRepository.save(eventEntity);
if (isNew) triggerWebhookAsync(issue);
triggerSymbolicationAsync(issue.getId(), item.appKey(), item.platform(), item.release());
}
@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 release) {
log.debug("Symbolication triggered issueId={} appKey={} platform={} release={}", issueId, appKey, platform, release);
// 查找 sourcemap 文件
String storageKey = sourcemapService.findSourceMap(appKey, platform, release, "index");
if (storageKey == null) {
log.debug("No sourcemap found for appKey={} platform={} release={}", appKey, platform, release);
return;
}
// 查找事件
LogIssueEventEntity event = issueEventRepository.findById(issueId).orElse(null);
if (event == null || event.getStack() == null || event.getStack().isBlank()) {
return;
}
try {
// 读取 sourcemap 内容
String sourcemapContent = sourcemapService.readSourceMapContent(storageKey);
if (sourcemapContent == null) {
log.warn("Failed to read sourcemap content for storageKey={}", storageKey);
return;
}
// 确定平台类型
String symbolicatePlatform = mapPlatform(platform);
// 调用符号化服务
String symbolicated = callSymbolicator(symbolicatePlatform, event.getStack(), sourcemapContent);
if (symbolicated != null && !symbolicated.equals(event.getStack())) {
event.setStackSymbolicated(symbolicated);
issueEventRepository.save(event);
log.info("Symbolicated issueId={} platform={}", issueId, platform);
}
} catch (Exception e) {
log.error("Symbolication failed for issueId={}: {}", issueId, e.getMessage(), e);
}
}
private String mapPlatform(String platform) {
if (platform == null) return "rn";
return switch (platform.toLowerCase()) {
case "android" -> "android";
case "ios" -> "ios";
case "flutter" -> "flutter";
default -> "rn"; // RN/Electron/Vue3 都使用 JS sourcemap
};
}
private String callSymbolicator(String platform, String stacktrace, String sourcemapContent) {
try {
org.springframework.web.client.RestTemplate restTemplate = new org.springframework.web.client.RestTemplate();
java.util.Map<String, Object> request = java.util.Map.of(
"platform", platform,
"stacktrace", stacktrace,
"sourcemap", sourcemapContent
);
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
org.springframework.http.HttpEntity<java.util.Map<String, Object>> entity = new org.springframework.http.HttpEntity<>(request, headers);
java.util.Map response = restTemplate.postForObject(
symbolicatorUrl + "/api/symbolicate",
entity,
java.util.Map.class
);
if (response != null && Boolean.TRUE.equals(response.get("success"))) {
return (String) response.get("symbolicated");
}
} catch (Exception e) {
log.error("Symbolicator service call failed: {}", e.getMessage());
}
return null;
}
@Transactional
public void processEventBatch(EventBatchRequest request) {
for (EventBatchRequest.EventItem item : request.events()) {
try {
if (item.eventId() != null && !item.eventId().isBlank()
&& eventRepository.existsByAppKeyAndEventId(item.appKey(), item.eventId())) {
continue;
}
LogEventEntity entity = new LogEventEntity();
entity.setEventId(item.eventId());
entity.setAppKey(item.appKey());
entity.setName(item.name());
entity.setUserId(item.user() != null ? item.user().id() : null);
entity.setProperties(item.properties() != null ? toJson(item.properties()) : null);
entity.setPlatform(item.platform());
entity.setRelease(item.release());
entity.setEnvironment(item.environment() != null ? item.environment() : "production");
entity.setDevice(item.device() != null ? toJson(item.device()) : null);
entity.setSdkName(item.sdk() != null ? item.sdk().name() : null);
entity.setSdkVersion(item.sdk() != null ? item.sdk().version() : null);
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 level, String platform,
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
String status, String q, String assignee, String environment,
String from, String to, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt"));
Page<LogIssueEntity> result = issueRepository.findByFilters(
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
appKey, level, platform, status, q, assignee, environment, parseDate(from), parseDate(to), 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));
Pageable pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt"));
List<IssueEventResponse> events = issueEventRepository
.findByIssueIdOrderByCreatedAtDesc(issue.getId(), pageable)
.getContent()
.stream()
.map(this::toIssueEventResponse)
.toList();
return new IssueResponse(
issue.getId(), issue.getAppKey(), issue.getFingerprint(),
issue.getLevel(),
issue.getType() != null ? issue.getType() : issue.getLevel(),
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
issue.getStatus() != null ? issue.getStatus() : "open",
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
issue.getCount(), issue.getAffectedUsers(),
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), events
);
}
@Transactional(readOnly = true)
public Page<IssueEventResponse> getIssueEvents(Long issueId, String from, String to, int page, int size) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("Issue not found: " + issueId);
}
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to) != null ? parseDate(to).plusDays(1) : null;
Page<LogIssueEventEntity> result = (fromDate != null && toDate != null)
? issueEventRepository.findByIssueIdAndCreatedAtBetweenOrderByCreatedAtDesc(issueId, fromDate, toDate, pageable)
: issueEventRepository.findByIssueIdOrderByCreatedAtDesc(issueId, pageable);
return result.map(this::toIssueEventResponse);
}
@Transactional(readOnly = true)
public IssueTrendResponse getIssueTrend(Long issueId, String from, String to) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("Issue not found: " + issueId);
}
LocalDate fromDate = from != null ? LocalDate.parse(from) : LocalDate.now().minusDays(13);
LocalDate toDate = to != null ? LocalDate.parse(to) : LocalDate.now();
List<Object[]> rows = issueEventRepository.findDailyCountsByIssueId(
issueId, fromDate.atStartOfDay(), toDate.plusDays(1).atStartOfDay());
Map<String, long[]> dayMap = new LinkedHashMap<>();
for (LocalDate c = fromDate; !c.isAfter(toDate); c = c.plusDays(1)) {
dayMap.put(c.toString(), new long[]{0, 0});
}
for (Object[] row : rows) {
LocalDateTime dt = (LocalDateTime) row[0];
String day = dt.toLocalDate().toString();
dayMap.put(day, new long[]{((Number) row[1]).longValue(), ((Number) row[2]).longValue()});
}
List<IssueTrendResponse.TrendPoint> points = dayMap.entrySet().stream()
.map(e -> new IssueTrendResponse.TrendPoint(e.getKey(), e.getValue()[0], e.getValue()[1]))
.toList();
return new IssueTrendResponse(issueId, points);
}
@Transactional
public void resolveIssue(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
issue.setStatus("resolved");
issueRepository.save(issue);
}
@Transactional
public void ignoreIssue(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
issue.setStatus("ignored");
issueRepository.save(issue);
}
@Transactional
public void reopenIssue(Long id) {
LogIssueEntity issue = issueRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Issue not found: " + id));
issue.setStatus("open");
issueRepository.save(issue);
}
@Transactional
public void assignIssue(Long id, String assignee) {
issueRepository.updateAssignee(id, assignee);
}
@Transactional
public void bulkUpdateIssues(String appKey, List<Long> ids, String action) {
String status = switch (action) {
case "resolve" -> "resolved";
case "ignore" -> "ignored";
case "reopen" -> "open";
default -> throw new IllegalArgumentException("Unknown action: " + action);
};
issueRepository.bulkUpdateStatus(appKey, ids, status);
}
@Transactional(readOnly = true)
public List<IssueResponse> getFrequencyRankings(String appKey, String from, String to, int limit) {
return issueRepository.findTopByFrequency(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
.stream().map(this::toIssueResponse).toList();
}
@Transactional(readOnly = true)
public List<IssueResponse> getRiskRankings(String appKey, String from, String to, int limit) {
return issueRepository.findTopByRisk(appKey, parseDate(from), parseDate(to), PageRequest.of(0, limit))
.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) != null ? parseDate(to).plusDays(1) : LocalDateTime.now();
if (fromDate == null) fromDate = LocalDateTime.now().minusDays(7);
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<LogEventEntity> result;
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);
}
return result.map(e -> new IssueEventResponse(
e.getId(), null, e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
null, null, null, parseJsonField(e.getDevice()),
e.getPlatform(), e.getRelease(), e.getEnvironment(),
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt()
));
}
@Transactional(readOnly = true)
public FunnelResponse queryFunnel(String appKey, List<String> steps, String from, String to) {
List<Object[]> rawData = eventRepository.findFunnelData(appKey, steps, parseDate(from), parseDate(to));
Map<String, Set<String>> sessionsPerStep = new LinkedHashMap<>();
for (String step : steps) sessionsPerStep.put(step, new HashSet<>());
for (Object[] row : rawData) {
String sid = (String) row[0];
String name = (String) row[1];
if (sessionsPerStep.containsKey(name) && sid != null) sessionsPerStep.get(name).add(sid);
}
List<Long> counts = steps.stream().map(s -> (long) sessionsPerStep.get(s).size()).toList();
long first = counts.isEmpty() ? 1 : Math.max(counts.getFirst(), 1);
List<Double> rates = counts.stream().map(c -> Math.round((double) c / first * 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);
long openIssues = issueRepository.countByAppKeyAndStatus(appKey, "open");
long todayNewIssues = issueRepository.countByAppKeyAndFirstSeenAtAfter(appKey, LocalDate.now().atStartOfDay());
// 计算影响用户数(所有 Issue 的 affectedUsers 之和)
long affectedUsers = issueRepository.sumAffectedUsers(appKey);
// 计算崩溃率fatal + error 占总数的百分比)
long fatalCount = issueRepository.countByAppKeyAndLevel(appKey, "fatal");
long errorCount = issueRepository.countByAppKeyAndLevel(appKey, "error");
double crashRate = totalIssues > 0 ? ((double)(fatalCount + errorCount) / totalIssues) * 100 : 0;
List<OverviewResponse.DailyCrashRate> trend = new ArrayList<>();
if (fromDate != null && toDate != null) {
for (LocalDate c = fromDate.toLocalDate(), end = toDate.toLocalDate(); !c.isAfter(end); c = c.plusDays(1)) {
long dayCount = issueRepository.countByAppKeyAndFirstSeenAtBetween(
appKey, c.atStartOfDay(), c.plusDays(1).atStartOfDay());
trend.add(new OverviewResponse.DailyCrashRate(c.toString(), dayCount, 0.0));
}
}
// 获取高频错误 Top 5
List<OverviewResponse.TopIssue> topIssues = issueRepository.findTopByFrequency(appKey, fromDate, toDate, PageRequest.of(0, 5))
.stream()
.map(issue -> new OverviewResponse.TopIssue(
issue.getId(),
issue.getTitle(),
issue.getType() != null ? issue.getType() : issue.getLevel(),
issue.getCount()))
.toList();
return new OverviewResponse(totalIssues, todayNewIssues, affectedUsers, crashRate, trend, topIssues);
}
/**
* 获取统计数据级别分布状态分布平台分布版本分布每日趋势按级别堆叠
*/
@Transactional(readOnly = true)
public StatisticsResponse getStatistics(String appKey, String from, String to) {
LocalDateTime fromDate = parseDate(from);
LocalDateTime toDate = parseDate(to);
// 级别分布
List<StatisticsResponse.DistributionItem> levelDist = issueRepository.countByLevel(appKey, fromDate, toDate)
.stream()
.map(row -> new StatisticsResponse.DistributionItem(
row[0] != null ? row[0].toString() : "unknown",
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
.toList();
// 状态分布
List<StatisticsResponse.DistributionItem> statusDist = issueRepository.countByStatus(appKey, fromDate, toDate)
.stream()
.map(row -> new StatisticsResponse.DistributionItem(
row[0] != null ? row[0].toString() : "unknown",
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
.toList();
// 平台分布
List<StatisticsResponse.DistributionItem> platformDist = issueRepository.countByPlatform(appKey, fromDate, toDate)
.stream()
.map(row -> new StatisticsResponse.DistributionItem(
row[0] != null ? row[0].toString() : "unknown",
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
.toList();
// 版本分布Top 10
List<StatisticsResponse.DistributionItem> versionDist = issueRepository.countByVersion(appKey, fromDate, toDate)
.stream()
.limit(10)
.map(row -> new StatisticsResponse.DistributionItem(
row[0] != null ? row[0].toString() : "unknown",
row[1] instanceof Number ? ((Number) row[1]).longValue() : 0))
.toList();
// 每日趋势(按级别堆叠)
List<Object[]> dailyRaw = issueRepository.countDailyByLevel(appKey, fromDate, toDate);
Map<String, StatisticsResponse.DailyTrendItem> dailyMap = new LinkedHashMap<>();
for (Object[] row : dailyRaw) {
String day = row[0] != null ? row[0].toString() : "";
String level = row[1] != null ? row[1].toString() : "unknown";
long count = row[2] instanceof Number ? ((Number) row[2]).longValue() : 0;
StatisticsResponse.DailyTrendItem existing = dailyMap.get(day);
if (existing == null) {
existing = new StatisticsResponse.DailyTrendItem(day, 0, 0, 0, 0);
}
long fatal = existing.fatal() + ("fatal".equals(level) ? count : 0);
long error = existing.error() + ("error".equals(level) ? count : 0);
long warning = existing.warning() + ("warning".equals(level) ? count : 0);
long total = existing.total() + count;
dailyMap.put(day, new StatisticsResponse.DailyTrendItem(day, fatal, error, warning, total));
}
return new StatisticsResponse(levelDist, statusDist, platformDist, versionDist, new ArrayList<>(dailyMap.values()));
}
@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.getLevel(),
issue.getType() != null ? issue.getType() : issue.getLevel(),
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
issue.getStatus() != null ? issue.getStatus() : "open",
issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(),
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
issue.getCount(), issue.getAffectedUsers(),
issue.getAssignee(), issue.getPlatform(), issue.getRelease(), null
);
}
private IssueEventResponse toIssueEventResponse(LogIssueEventEntity e) {
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
IssueEventResponse.ExceptionInfo exception = new IssueEventResponse.ExceptionInfo(
e.getExceptionType(),
e.getExceptionValue() != null ? e.getExceptionValue() : e.getMessage(),
e.getStack(),
e.getStackSymbolicated()
);
return new IssueEventResponse(
e.getId(), e.getIssueId(), e.getEventId(), e.getAppKey(), e.getUserId(), e.getSessionId(),
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
exception,
parseJsonField(e.getBreadcrumbs()),
parseJsonField(e.getTags()),
parseJsonField(e.getDevice()),
e.getPlatform(), e.getRelease(), e.getEnvironment(),
e.getSdkName(), e.getSdkVersion(), e.getCreatedAt()
);
}
fix(bugcollect): 修复 BugCollect-API-v1-Review 全量37项问题 服务端: - IssueResponse 移除 isResolved,统一使用 status 字段 - IssueEventResponse 重构为 ExceptionInfo 嵌套结构,breadcrumbs/tags/device 序列化为 Object - IssueResponse 新增 affectedUsers 字段,通过 log_issue_users 去重计数 - WebhookResponse 将 secret 替换为 hasSecret boolean - WebhookRequest 新增 secret 字段 - LogWebhookEntity 新增 secret 列 - 新增 LogIssueUserEntity / LogIssueUserRepository 实现 affectedUsers 去重 - LogIssueRepository.findByFilters 新增 assignee、environment 过滤 - LogService.queryIssues 新增 assignee、environment 参数 - LogService.toIssueResponse 修正 IssueResponse 构造(移除 isResolved) - LogService.toIssueEventResponse 修正 ExceptionInfo 构造,JSON 字段反序列化为 Object - LogController:assign 改为 @RequestBody;GET /issues 新增 assignee/environment 参数;upload 新增 bundleVersion 参数 - WebhookService:事件类型匹配改为 issue.created/issue.fatal/issue.error/issue.warning/issue.updated;新增 HMAC-SHA256 X-BugCollect-Signature 签名;toResponse 修正为 hasSecret - SourcemapService:按平台生成正确后缀(.android.bundle.map/.ios.bundle.map);新增 bundleVersion 参数;200MB 大小限制;.map 扩展名校验 - V4 迁移 SQL:log_issue_users 表、log_webhooks.secret 列、log_sessions 表、event_id 索引 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 09:26:12 +08:00
private Object parseJsonField(String json) {
if (json == null || json.isBlank()) return null;
try {
return objectMapper.readValue(json, Object.class);
} catch (Exception ex) {
return json;
}
}
private String toJson(Object obj) {
if (obj == null) return null;
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
return obj.toString();
}
}
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; }
}
}