feat: update-service 签名验证 + bugcollect 统计图表 API + 修复字段映射

- 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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-19 01:22:46 +08:00
父节点 0871d5cfab
当前提交 5b1e04b6d1
共有 9 个文件被更改,包括 269 次插入6 次删除

查看文件

@ -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)

查看文件

@ -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<String, String> 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;
}
}

查看文件

@ -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) {

查看文件

@ -182,6 +182,14 @@ public class LogController {
return ApiResponse.success(logService.getOverview(appKey, from, to));
}
@GetMapping("/statistics")
public ApiResponse<StatisticsResponse> 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")

查看文件

@ -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,

查看文件

@ -7,11 +7,20 @@ public record OverviewResponse(
@JsonProperty("totalIssues") long totalIssues,
@JsonProperty("todayNewIssues") long todayNewIssues,
@JsonProperty("affectedUsers") long affectedUsers,
List<DailyCrashRate> crashTrend
@JsonProperty("crashRate") double crashRate,
@JsonProperty("crashRateTrend") List<DailyCrashRate> crashRateTrend,
@JsonProperty("topIssues") List<TopIssue> 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
) {}
}

查看文件

@ -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<DistributionItem> levelDistribution,
/** 按状态分布open / resolved / ignored */
List<DistributionItem> statusDistribution,
/** 按平台分布 */
List<DistributionItem> platformDistribution,
/** 按版本分布 */
List<DistributionItem> versionDistribution,
/** 每日趋势(按级别堆叠) */
List<DailyTrendItem> 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
) {}
}

查看文件

@ -91,5 +91,57 @@ public interface LogIssueRepository extends JpaRepository<LogIssueEntity, Long>
@Query("SELECT DISTINCT i.platform FROM LogIssueEntity i WHERE i.appKey = :appKey")
List<String> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> countDailyByLevel(@Param("appKey") String appKey,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
void deleteByAppKey(String appKey);
}

查看文件

@ -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<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)) {
@ -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<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
@ -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(),