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>
这个提交包含在:
父节点
0871d5cfab
当前提交
5b1e04b6d1
@ -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(),
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户