From 5b1e04b6d16b094f428cd5fe77bbb5b241673b39 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 19 Jun 2026 01:22:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20update-service=20=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=20+=20bugcollect=20=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=20API=20+=20=E4=BF=AE=E5=A4=8D=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update-service: 集成 AppSignatureAuthFilter 签名验证 - update-service: 添加 TenantAppSecretResolver - bugcollect: 新增 /statistics 统计 API 端点 - bugcollect: 修复 OverviewResponse 字段名(crashRateTrend/topIssues/crashRate) - bugcollect: 修复 IssueResponse 添加 type 字段 - bugcollect: 修复 affectedUsers 语义(从 openIssues 改为真正的受影响用户数) - bugcollect: 新增 StatisticsResponse DTO - bugcollect: LogIssueRepository 新增统计查询方法 Co-Authored-By: Claude --- .../xuqm/update/config/SecurityConfig.java | 8 +- .../config/TenantAppSecretResolver.java | 67 +++++++++++++++ .../controller/AppVersionController.java | 9 +- .../bugcollect/controller/LogController.java | 8 ++ .../xuqm/bugcollect/dto/IssueResponse.java | 1 + .../xuqm/bugcollect/dto/OverviewResponse.java | 11 ++- .../bugcollect/dto/StatisticsResponse.java | 34 ++++++++ .../repository/LogIssueRepository.java | 52 ++++++++++++ .../xuqm/bugcollect/service/LogService.java | 85 ++++++++++++++++++- 9 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java create mode 100644 xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/StatisticsResponse.java diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index 02ae3c8..ad47b20 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.xuqm.update.config; import com.xuqm.common.security.ApiKeyAuthFilter; +import com.xuqm.common.security.AppSignatureAuthFilter; import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtUtil; import org.springframework.context.annotation.Bean; @@ -25,10 +26,13 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final TenantApiKeyValidator apiKeyValidator; + private final TenantAppSecretResolver appSecretResolver; - public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator) { + public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator, + TenantAppSecretResolver appSecretResolver) { this.jwtUtil = jwtUtil; this.apiKeyValidator = apiKeyValidator; + this.appSecretResolver = appSecretResolver; } @Bean @@ -56,6 +60,8 @@ public class SecurityConfig { ) // API Key 认证(通用过滤器 + tenant-service 验证) .addFilterBefore(new ApiKeyAuthFilter(apiKeyValidator), UsernamePasswordAuthenticationFilter.class) + // HMAC 签名验证(SDK 请求) + .addFilterBefore(new AppSignatureAuthFilter(appSecretResolver), UsernamePasswordAuthenticationFilter.class) // JWT 认证 .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) .httpBasic(AbstractHttpConfigurer::disable) diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java new file mode 100644 index 0000000..3b9ec88 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java @@ -0,0 +1,67 @@ +package com.xuqm.update.config; + +import com.xuqm.common.security.AppSignatureAuthFilter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 通过 tenant-service 内部 API 解析 appSecret。 + */ +@Component +public class TenantAppSecretResolver implements AppSignatureAuthFilter.AppSecretResolver { + + private static final Logger log = LoggerFactory.getLogger(TenantAppSecretResolver.class); + + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") + private String tenantServiceUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** 缓存:appKey → appSecret */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + public String resolveAppSecret(String appKey) { + // 先查缓存 + String cached = cache.get(appKey); + if (cached != null) return cached; + + try { + String url = tenantServiceUrl + "/api/internal/sdk/apps/" + appKey + "/secret"; + var headers = new org.springframework.http.HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + var entity = new org.springframework.http.HttpEntity<>(headers); + + var response = restTemplate.exchange( + url, + org.springframework.http.HttpMethod.GET, + entity, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode data = root.path("data"); + String appSecret = data.path("appSecret").asText(null); + if (appSecret != null) { + cache.put(appKey, appSecret); + } + return appSecret; + } + } catch (Exception e) { + log.warn("Failed to resolve appSecret for appKey={}: {}", appKey, e.getMessage()); + } + return null; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 93672c8..4fe4d5f 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -81,12 +81,13 @@ public class AppVersionController { @RequestParam(required = false) String deviceId, @RequestParam(required = false) String model, @RequestParam(required = false) String osVersion, - @RequestParam(required = false) String vendor) { + @RequestParam(required = false) String vendor, + @RequestParam(required = false) String currentVersionName) { String resolvedAppKey = resolveAndValidate(appKey, platform, licenseFile); // 异步记录设备信息(不影响响应性能) - recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode); + recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode, currentVersionName); boolean serviceActivated = tenantClient.isUpdateServiceEnabled(resolvedAppKey, platform.name()); boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey); @@ -608,7 +609,8 @@ public class AppVersionController { * 异常不影响主流程。 */ private void recordDevice(String appKey, String platform, String deviceId, String userId, - String model, String osVersion, String vendor, int currentVersionCode) { + String model, String osVersion, String vendor, int currentVersionCode, + String currentVersionName) { if ((deviceId == null || deviceId.isBlank()) && (model == null || model.isBlank())) { return; } @@ -623,6 +625,7 @@ public class AppVersionController { record.setOsVersion(osVersion); record.setVendor(vendor); record.setCurrentVersionCode(currentVersionCode); + record.setCurrentVersionName(currentVersionName); record.setCheckedAt(LocalDateTime.now()); deviceRecordRepository.save(record); } catch (Exception e) { diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java index 9c26fbc..73e240e 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/controller/LogController.java @@ -182,6 +182,14 @@ public class LogController { return ApiResponse.success(logService.getOverview(appKey, from, to)); } + @GetMapping("/statistics") + public ApiResponse getStatistics( + @RequestParam String appKey, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to) { + return ApiResponse.success(logService.getStatistics(appKey, from, to)); + } + // ── Sourcemaps ───────────────────────────────────────────────────────────── @PostMapping("/sourcemaps/upload") diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java index bec7d3c..374d94a 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/IssueResponse.java @@ -9,6 +9,7 @@ public record IssueResponse( @JsonProperty("appKey") String appKey, String fingerprint, String level, + String type, String status, String title, @JsonProperty("firstSeenAt") LocalDateTime firstSeenAt, diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/OverviewResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/OverviewResponse.java index 8f5b528..575cbf1 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/OverviewResponse.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/OverviewResponse.java @@ -7,11 +7,20 @@ public record OverviewResponse( @JsonProperty("totalIssues") long totalIssues, @JsonProperty("todayNewIssues") long todayNewIssues, @JsonProperty("affectedUsers") long affectedUsers, - List crashTrend + @JsonProperty("crashRate") double crashRate, + @JsonProperty("crashRateTrend") List crashRateTrend, + @JsonProperty("topIssues") List topIssues ) { public record DailyCrashRate( String date, @JsonProperty("crashCount") long crashCount, @JsonProperty("crashRate") double crashRate ) {} + + public record TopIssue( + @JsonProperty("id") Long id, + @JsonProperty("title") String title, + @JsonProperty("type") String type, + @JsonProperty("count") int count + ) {} } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/StatisticsResponse.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/StatisticsResponse.java new file mode 100644 index 0000000..dedae03 --- /dev/null +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/dto/StatisticsResponse.java @@ -0,0 +1,34 @@ +package com.xuqm.bugcollect.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * BugCollect 统计数据响应。 + * 用于概览页的图表展示。 + */ +public record StatisticsResponse( + /** 按级别分布:fatal / error / warning */ + List levelDistribution, + /** 按状态分布:open / resolved / ignored */ + List statusDistribution, + /** 按平台分布 */ + List platformDistribution, + /** 按版本分布 */ + List versionDistribution, + /** 每日趋势(按级别堆叠) */ + List dailyTrend +) { + public record DistributionItem( + String name, + @JsonProperty("value") long value + ) {} + + public record DailyTrendItem( + String date, + @JsonProperty("fatal") long fatal, + @JsonProperty("error") long error, + @JsonProperty("warning") long warning, + @JsonProperty("total") long total + ) {} +} diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java index e71337d..ad7ddfd 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/repository/LogIssueRepository.java @@ -91,5 +91,57 @@ public interface LogIssueRepository extends JpaRepository @Query("SELECT DISTINCT i.platform FROM LogIssueEntity i WHERE i.appKey = :appKey") List findDistinctPlatforms(@Param("appKey") String appKey); + /** 计算所有 Issue 的受影响用户总数 */ + @Query("SELECT COALESCE(SUM(i.affectedUsers), 0) FROM LogIssueEntity i WHERE i.appKey = :appKey") + long sumAffectedUsers(@Param("appKey") String appKey); + + /** 按级别统计 Issue 数量 */ + @Query("SELECT COUNT(i) FROM LogIssueEntity i WHERE i.appKey = :appKey AND i.level = :level") + long countByAppKeyAndLevel(@Param("appKey") String appKey, @Param("level") String level); + + /** 按级别统计 Issue 数量 */ + @Query("SELECT i.level, COUNT(i) FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND (:from IS NULL OR i.firstSeenAt >= :from) AND (:to IS NULL OR i.firstSeenAt <= :to) " + + "GROUP BY i.level") + List countByLevel(@Param("appKey") String appKey, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + /** 按状态统计 Issue 数量 */ + @Query("SELECT i.status, COUNT(i) FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND (:from IS NULL OR i.firstSeenAt >= :from) AND (:to IS NULL OR i.firstSeenAt <= :to) " + + "GROUP BY i.status") + List countByStatus(@Param("appKey") String appKey, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + /** 按平台统计 Issue 数量 */ + @Query("SELECT i.platform, COUNT(i) FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND (:from IS NULL OR i.firstSeenAt >= :from) AND (:to IS NULL OR i.firstSeenAt <= :to) " + + "GROUP BY i.platform") + List countByPlatform(@Param("appKey") String appKey, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + /** 按版本统计 Issue 数量 */ + @Query("SELECT i.release, COUNT(i) FROM LogIssueEntity i WHERE i.appKey = :appKey " + + "AND i.release IS NOT NULL AND i.release != '' " + + "AND (:from IS NULL OR i.firstSeenAt >= :from) AND (:to IS NULL OR i.firstSeenAt <= :to) " + + "GROUP BY i.release ORDER BY COUNT(i) DESC") + List countByVersion(@Param("appKey") String appKey, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + + /** 按天按级别统计 Issue 数量(用于堆叠趋势图) */ + @Query(value = "SELECT DATE(i.first_seen_at) AS day, i.level, COUNT(*) AS cnt " + + "FROM log_issues i WHERE i.app_key = :appKey " + + "AND (:from IS NULL OR i.first_seen_at >= :from) " + + "AND (:to IS NULL OR i.first_seen_at <= :to) " + + "GROUP BY DATE(i.first_seen_at), i.level ORDER BY day", + nativeQuery = true) + List countDailyByLevel(@Param("appKey") String appKey, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); + void deleteByAppKey(String appKey); } diff --git a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java index 27b097f..5870de7 100644 --- a/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java +++ b/xuqm-bugcollect-service/src/main/java/com/xuqm/bugcollect/service/LogService.java @@ -356,6 +356,14 @@ public class LogService { 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 trend = new ArrayList<>(); if (fromDate != null && toDate != null) { for (LocalDate c = fromDate.toLocalDate(), end = toDate.toLocalDate(); !c.isAfter(end); c = c.plusDays(1)) { @@ -364,7 +372,81 @@ public class LogService { trend.add(new OverviewResponse.DailyCrashRate(c.toString(), dayCount, 0.0)); } } - return new OverviewResponse(totalIssues, todayNewIssues, openIssues, trend); + + // 获取高频错误 Top 5 + List 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 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 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 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 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 dailyRaw = issueRepository.countDailyByLevel(appKey, fromDate, toDate); + Map 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 @@ -377,6 +459,7 @@ public class LogService { return new IssueResponse( issue.getId(), issue.getAppKey(), issue.getFingerprint(), issue.getLevel(), + issue.getType() != null ? issue.getType() : issue.getLevel(), issue.getStatus() != null ? issue.getStatus() : "open", issue.getTitle(), issue.getFirstSeenAt(), issue.getLastSeenAt(), issue.getCount(), issue.getAffectedUsers(),