fix: bugcollect controller 路径从 /bugcollect/v1 改为 /api/bugcollect/v1,匹配 nginx 路由
这个提交包含在:
父节点
62163fac7b
当前提交
8ac2a37e7f
@ -0,0 +1,111 @@
|
|||||||
|
package com.xuqm.common.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 SDK 请求的 HMAC-SHA256 签名。
|
||||||
|
* <p>
|
||||||
|
* 请求头:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code X-App-Key} — 应用标识</li>
|
||||||
|
* <li>{@code X-Timestamp} — Unix 时间戳(秒)</li>
|
||||||
|
* <li>{@code X-Nonce} — 随机字符串(防重放)</li>
|
||||||
|
* <li>{@code X-Signature} — HMAC-SHA256 签名</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>
|
||||||
|
* 签名载荷格式:{@code appKey\nuserId\ntimestamp\nnonce}
|
||||||
|
*/
|
||||||
|
public class AppSignatureAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AppSignatureAuthFilter.class);
|
||||||
|
private static final long MAX_TIMESTAMP_DRIFT_SECONDS = 300; // ±5 分钟
|
||||||
|
|
||||||
|
private final AppSecretResolver secretResolver;
|
||||||
|
|
||||||
|
public AppSignatureAuthFilter(AppSecretResolver secretResolver) {
|
||||||
|
this.secretResolver = secretResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String appKey = request.getHeader("X-App-Key");
|
||||||
|
String timestampStr = request.getHeader("X-Timestamp");
|
||||||
|
String nonce = request.getHeader("X-Nonce");
|
||||||
|
String signature = request.getHeader("X-Signature");
|
||||||
|
|
||||||
|
// 如果没有签名头,跳过验证(交给后续 Filter 处理)
|
||||||
|
if (appKey == null || signature == null) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证时间戳
|
||||||
|
if (timestampStr == null || timestampStr.isBlank()) {
|
||||||
|
log.warn("Missing X-Timestamp header for appKey={}", appKey);
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing timestamp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long timestamp;
|
||||||
|
try {
|
||||||
|
timestamp = Long.parseLong(timestampStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
log.warn("Invalid X-Timestamp header: {} for appKey={}", timestampStr, appKey);
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid timestamp");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = Instant.now().getEpochSecond();
|
||||||
|
if (Math.abs(now - timestamp) > MAX_TIMESTAMP_DRIFT_SECONDS) {
|
||||||
|
log.warn("Timestamp drift too large: now={}, request={}, appKey={}", now, timestamp, appKey);
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Timestamp expired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 appSecret
|
||||||
|
String appSecret;
|
||||||
|
try {
|
||||||
|
appSecret = secretResolver.resolveAppSecret(appKey);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to resolve appSecret for appKey={}: {}", appKey, e.getMessage());
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid appKey");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appSecret == null || appSecret.isBlank()) {
|
||||||
|
log.warn("No appSecret found for appKey={}", appKey);
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid appKey");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造签名载荷并验证
|
||||||
|
String userId = request.getHeader("X-User-Id"); // 可选
|
||||||
|
String payload = AppRequestSignatureUtil.payload(appKey, userId, timestamp, nonce);
|
||||||
|
if (!AppRequestSignatureUtil.matches(appSecret, payload, signature)) {
|
||||||
|
log.warn("Signature mismatch for appKey={}", appKey);
|
||||||
|
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid signature");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名验证通过,继续处理请求
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 appKey 解析 appSecret。
|
||||||
|
* 各服务需实现此接口以查询 appSecret。
|
||||||
|
*/
|
||||||
|
public interface AppSecretResolver {
|
||||||
|
String resolveAppSecret(String appKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -72,7 +72,8 @@ public final class ConfigFileCrypto {
|
|||||||
text(node, "iosBundleId"),
|
text(node, "iosBundleId"),
|
||||||
text(node, "harmonyBundleName"),
|
text(node, "harmonyBundleName"),
|
||||||
text(node, "baseUrl"),
|
text(node, "baseUrl"),
|
||||||
text(node, "serverUrl"));
|
text(node, "serverUrl"),
|
||||||
|
text(node, "signingKey"));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -105,7 +106,8 @@ public final class ConfigFileCrypto {
|
|||||||
String iosBundleId,
|
String iosBundleId,
|
||||||
String harmonyBundleName,
|
String harmonyBundleName,
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String serverUrl) {
|
String serverUrl,
|
||||||
|
String signingKey) {
|
||||||
|
|
||||||
public boolean matchesPackageName(String candidate) {
|
public boolean matchesPackageName(String candidate) {
|
||||||
if (candidate == null || candidate.isBlank()) return false;
|
if (candidate == null || candidate.isBlank()) return false;
|
||||||
|
|||||||
@ -195,6 +195,7 @@ public class AppService {
|
|||||||
payload.put("harmonyBundleName", app.getHarmonyBundleName());
|
payload.put("harmonyBundleName", app.getHarmonyBundleName());
|
||||||
}
|
}
|
||||||
payload.put("serverUrl", normalizeBaseUrl(sdkPlatformPublicBaseUrl));
|
payload.put("serverUrl", normalizeBaseUrl(sdkPlatformPublicBaseUrl));
|
||||||
|
payload.put("signingKey", app.getAppSecret());
|
||||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||||
try {
|
try {
|
||||||
return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
||||||
|
|||||||
@ -2,7 +2,9 @@ package com.xuqm.update.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.update.entity.AppVersionEntity;
|
import com.xuqm.update.entity.AppVersionEntity;
|
||||||
|
import com.xuqm.update.entity.DeviceRecordEntity;
|
||||||
import com.xuqm.update.repository.AppVersionRepository;
|
import com.xuqm.update.repository.AppVersionRepository;
|
||||||
|
import com.xuqm.update.repository.DeviceRecordRepository;
|
||||||
import com.xuqm.update.model.AppPackageInspectResult;
|
import com.xuqm.update.model.AppPackageInspectResult;
|
||||||
import com.xuqm.update.service.UpdateOperationLogService;
|
import com.xuqm.update.service.UpdateOperationLogService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -34,6 +36,7 @@ public class AppVersionController {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(AppVersionController.class);
|
private static final Logger log = LoggerFactory.getLogger(AppVersionController.class);
|
||||||
|
|
||||||
private final AppVersionRepository versionRepository;
|
private final AppVersionRepository versionRepository;
|
||||||
|
private final DeviceRecordRepository deviceRecordRepository;
|
||||||
private final UpdateAssetService updateAssetService;
|
private final UpdateAssetService updateAssetService;
|
||||||
private final PublishConfigService publishConfigService;
|
private final PublishConfigService publishConfigService;
|
||||||
private final AppStoreService appStoreService;
|
private final AppStoreService appStoreService;
|
||||||
@ -45,6 +48,7 @@ public class AppVersionController {
|
|||||||
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
|
||||||
|
|
||||||
public AppVersionController(AppVersionRepository versionRepository,
|
public AppVersionController(AppVersionRepository versionRepository,
|
||||||
|
DeviceRecordRepository deviceRecordRepository,
|
||||||
UpdateAssetService updateAssetService,
|
UpdateAssetService updateAssetService,
|
||||||
PublishConfigService publishConfigService,
|
PublishConfigService publishConfigService,
|
||||||
AppStoreService appStoreService,
|
AppStoreService appStoreService,
|
||||||
@ -55,6 +59,7 @@ public class AppVersionController {
|
|||||||
GrayMemberService grayMemberService,
|
GrayMemberService grayMemberService,
|
||||||
com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
|
com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
|
||||||
this.versionRepository = versionRepository;
|
this.versionRepository = versionRepository;
|
||||||
|
this.deviceRecordRepository = deviceRecordRepository;
|
||||||
this.updateAssetService = updateAssetService;
|
this.updateAssetService = updateAssetService;
|
||||||
this.publishConfigService = publishConfigService;
|
this.publishConfigService = publishConfigService;
|
||||||
this.appStoreService = appStoreService;
|
this.appStoreService = appStoreService;
|
||||||
@ -72,9 +77,16 @@ public class AppVersionController {
|
|||||||
@RequestParam AppVersionEntity.Platform platform,
|
@RequestParam AppVersionEntity.Platform platform,
|
||||||
@RequestParam int currentVersionCode,
|
@RequestParam int currentVersionCode,
|
||||||
@RequestParam(required = false) String licenseFile,
|
@RequestParam(required = false) String licenseFile,
|
||||||
@RequestParam(required = false) String userId) {
|
@RequestParam(required = false) String userId,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
|
@RequestParam(required = false) String model,
|
||||||
|
@RequestParam(required = false) String osVersion,
|
||||||
|
@RequestParam(required = false) String vendor) {
|
||||||
|
|
||||||
String resolvedAppKey = resolveAndValidate(appKey, platform, licenseFile);
|
String resolvedAppKey = resolveAndValidate(appKey, platform, licenseFile);
|
||||||
|
|
||||||
|
// 异步记录设备信息(不影响响应性能)
|
||||||
|
recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode);
|
||||||
boolean serviceActivated = tenantClient.isUpdateServiceEnabled(resolvedAppKey, platform.name());
|
boolean serviceActivated = tenantClient.isUpdateServiceEnabled(resolvedAppKey, platform.name());
|
||||||
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
|
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
|
||||||
|
|
||||||
@ -589,4 +601,32 @@ public class AppVersionController {
|
|||||||
return "[]";
|
return "[]";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步记录设备信息到 update_device_record 表。
|
||||||
|
* 仅当 deviceId 或 model 有值时才记录,避免空数据。
|
||||||
|
* 异常不影响主流程。
|
||||||
|
*/
|
||||||
|
private void recordDevice(String appKey, String platform, String deviceId, String userId,
|
||||||
|
String model, String osVersion, String vendor, int currentVersionCode) {
|
||||||
|
if ((deviceId == null || deviceId.isBlank()) && (model == null || model.isBlank())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
DeviceRecordEntity record = new DeviceRecordEntity();
|
||||||
|
record.setId(UUID.randomUUID().toString());
|
||||||
|
record.setAppKey(appKey);
|
||||||
|
record.setPlatform(platform);
|
||||||
|
record.setDeviceId(deviceId);
|
||||||
|
record.setUserId(userId);
|
||||||
|
record.setModel(model);
|
||||||
|
record.setOsVersion(osVersion);
|
||||||
|
record.setVendor(vendor);
|
||||||
|
record.setCurrentVersionCode(currentVersionCode);
|
||||||
|
record.setCheckedAt(LocalDateTime.now());
|
||||||
|
deviceRecordRepository.save(record);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to record device info for appKey={}: {}", appKey, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,59 @@
|
|||||||
|
package com.xuqm.update.controller;
|
||||||
|
|
||||||
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.update.service.UpdateStatisticsService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/updates/stats")
|
||||||
|
public class UpdateStatisticsController {
|
||||||
|
|
||||||
|
private final UpdateStatisticsService statisticsService;
|
||||||
|
|
||||||
|
public UpdateStatisticsController(UpdateStatisticsService statisticsService) {
|
||||||
|
this.statisticsService = statisticsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本分布:各版本的设备数占比(饼图/环形图数据)
|
||||||
|
*/
|
||||||
|
@GetMapping("/version-distribution")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getVersionDistribution(
|
||||||
|
@RequestParam String appKey,
|
||||||
|
@RequestParam String platform,
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
List<Map<String, Object>> data = statisticsService.getVersionDistribution(appKey, platform, days);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备型号分布 Top 10(柱状图数据)
|
||||||
|
*/
|
||||||
|
@GetMapping("/device-distribution")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, List<Map<String, Object>>>>> getDeviceDistribution(
|
||||||
|
@RequestParam String appKey,
|
||||||
|
@RequestParam String platform,
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
Map<String, List<Map<String, Object>>> data = new java.util.LinkedHashMap<>();
|
||||||
|
data.put("models", statisticsService.getDeviceDistribution(appKey, platform, days));
|
||||||
|
data.put("osVersions", statisticsService.getOsVersionDistribution(appKey, platform, days));
|
||||||
|
data.put("vendors", statisticsService.getVendorDistribution(appKey, platform, days));
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日检测趋势(折线图数据)
|
||||||
|
*/
|
||||||
|
@GetMapping("/daily-trend")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getDailyTrend(
|
||||||
|
@RequestParam String appKey,
|
||||||
|
@RequestParam String platform,
|
||||||
|
@RequestParam(defaultValue = "7") int days) {
|
||||||
|
List<Map<String, Object>> data = statisticsService.getDailyTrend(appKey, platform, days);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package com.xuqm.update.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "update_device_record")
|
||||||
|
public class DeviceRecordEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 64)
|
||||||
|
private String appKey;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 16)
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
@Column(length = 128)
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
@Column(length = 128)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@Column(length = 128)
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
@Column(length = 64)
|
||||||
|
private String osVersion;
|
||||||
|
|
||||||
|
@Column(length = 32)
|
||||||
|
private String vendor;
|
||||||
|
|
||||||
|
private Integer currentVersionCode;
|
||||||
|
|
||||||
|
@Column(length = 32)
|
||||||
|
private String currentVersionName;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime checkedAt;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getAppKey() { return appKey; }
|
||||||
|
public void setAppKey(String appKey) { this.appKey = appKey; }
|
||||||
|
|
||||||
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
public String getDeviceId() { return deviceId; }
|
||||||
|
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||||
|
|
||||||
|
public String getUserId() { return userId; }
|
||||||
|
public void setUserId(String userId) { this.userId = userId; }
|
||||||
|
|
||||||
|
public String getModel() { return model; }
|
||||||
|
public void setModel(String model) { this.model = model; }
|
||||||
|
|
||||||
|
public String getOsVersion() { return osVersion; }
|
||||||
|
public void setOsVersion(String osVersion) { this.osVersion = osVersion; }
|
||||||
|
|
||||||
|
public String getVendor() { return vendor; }
|
||||||
|
public void setVendor(String vendor) { this.vendor = vendor; }
|
||||||
|
|
||||||
|
public Integer getCurrentVersionCode() { return currentVersionCode; }
|
||||||
|
public void setCurrentVersionCode(Integer currentVersionCode) { this.currentVersionCode = currentVersionCode; }
|
||||||
|
|
||||||
|
public String getCurrentVersionName() { return currentVersionName; }
|
||||||
|
public void setCurrentVersionName(String currentVersionName) { this.currentVersionName = currentVersionName; }
|
||||||
|
|
||||||
|
public LocalDateTime getCheckedAt() { return checkedAt; }
|
||||||
|
public void setCheckedAt(LocalDateTime checkedAt) { this.checkedAt = checkedAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.xuqm.update.repository;
|
||||||
|
|
||||||
|
import com.xuqm.update.entity.DeviceRecordEntity;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface DeviceRecordRepository extends JpaRepository<DeviceRecordEntity, String> {
|
||||||
|
|
||||||
|
/** 版本分布:按 currentVersionCode 分组统计设备数 */
|
||||||
|
@Query(value = """
|
||||||
|
SELECT current_version_code AS versionCode,
|
||||||
|
current_version_name AS versionName,
|
||||||
|
COUNT(DISTINCT device_id) AS deviceCount
|
||||||
|
FROM update_device_record
|
||||||
|
WHERE app_key = :appKey AND platform = :platform AND checked_at >= :since
|
||||||
|
GROUP BY current_version_code, current_version_name
|
||||||
|
ORDER BY current_version_code DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findVersionDistribution(@Param("appKey") String appKey,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
/** 设备型号分布 Top N */
|
||||||
|
@Query(value = """
|
||||||
|
SELECT model, COUNT(DISTINCT device_id) AS deviceCount
|
||||||
|
FROM update_device_record
|
||||||
|
WHERE app_key = :appKey AND platform = :platform AND checked_at >= :since AND model IS NOT NULL AND model != ''
|
||||||
|
GROUP BY model
|
||||||
|
ORDER BY deviceCount DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findModelDistribution(@Param("appKey") String appKey,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("since") LocalDateTime since,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
/** 系统版本分布 */
|
||||||
|
@Query(value = """
|
||||||
|
SELECT os_version AS osVersion, COUNT(DISTINCT device_id) AS deviceCount
|
||||||
|
FROM update_device_record
|
||||||
|
WHERE app_key = :appKey AND platform = :platform AND checked_at >= :since AND os_version IS NOT NULL AND os_version != ''
|
||||||
|
GROUP BY os_version
|
||||||
|
ORDER BY deviceCount DESC
|
||||||
|
LIMIT :limit
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findOsVersionDistribution(@Param("appKey") String appKey,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("since") LocalDateTime since,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
|
||||||
|
/** 厂商分布 */
|
||||||
|
@Query(value = """
|
||||||
|
SELECT vendor, COUNT(DISTINCT device_id) AS deviceCount
|
||||||
|
FROM update_device_record
|
||||||
|
WHERE app_key = :appKey AND platform = :platform AND checked_at >= :since AND vendor IS NOT NULL AND vendor != ''
|
||||||
|
GROUP BY vendor
|
||||||
|
ORDER BY deviceCount DESC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findVendorDistribution(@Param("appKey") String appKey,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
|
||||||
|
/** 每日检测趋势:按天统计设备数 */
|
||||||
|
@Query(value = """
|
||||||
|
SELECT DATE(checked_at) AS day, COUNT(DISTINCT device_id) AS deviceCount
|
||||||
|
FROM update_device_record
|
||||||
|
WHERE app_key = :appKey AND platform = :platform AND checked_at >= :since
|
||||||
|
GROUP BY DATE(checked_at)
|
||||||
|
ORDER BY day ASC
|
||||||
|
""", nativeQuery = true)
|
||||||
|
List<Object[]> findDailyTrend(@Param("appKey") String appKey,
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("since") LocalDateTime since);
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.xuqm.update.service;
|
||||||
|
|
||||||
|
import com.xuqm.update.repository.DeviceRecordRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UpdateStatisticsService {
|
||||||
|
|
||||||
|
private final DeviceRecordRepository deviceRecordRepository;
|
||||||
|
|
||||||
|
public UpdateStatisticsService(DeviceRecordRepository deviceRecordRepository) {
|
||||||
|
this.deviceRecordRepository = deviceRecordRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本分布:各版本的设备数占比
|
||||||
|
* 返回 [{ versionCode, versionName, deviceCount }]
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getVersionDistribution(String appKey, String platform, int days) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
List<Object[]> rows = deviceRecordRepository.findVersionDistribution(appKey, platform, since);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("versionCode", row[0]);
|
||||||
|
item.put("versionName", row[1]);
|
||||||
|
item.put("deviceCount", row[2]);
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备型号分布 Top N
|
||||||
|
* 返回 [{ model, deviceCount }]
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getDeviceDistribution(String appKey, String platform, int days) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
List<Object[]> models = deviceRecordRepository.findModelDistribution(appKey, platform, since, 10);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object[] row : models) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("model", row[0]);
|
||||||
|
item.put("deviceCount", row[1]);
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统版本分布
|
||||||
|
* 返回 [{ osVersion, deviceCount }]
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getOsVersionDistribution(String appKey, String platform, int days) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
List<Object[]> rows = deviceRecordRepository.findOsVersionDistribution(appKey, platform, since, 10);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("osVersion", row[0]);
|
||||||
|
item.put("deviceCount", row[1]);
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 厂商分布
|
||||||
|
* 返回 [{ vendor, deviceCount }]
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getVendorDistribution(String appKey, String platform, int days) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
List<Object[]> rows = deviceRecordRepository.findVendorDistribution(appKey, platform, since);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("vendor", row[0]);
|
||||||
|
item.put("deviceCount", row[1]);
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日检测趋势
|
||||||
|
* 返回 [{ day, deviceCount }]
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getDailyTrend(String appKey, String platform, int days) {
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusDays(days);
|
||||||
|
List<Object[]> rows = deviceRecordRepository.findDailyTrend(appKey, platform, since);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object[] row : rows) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("day", row[0] != null ? row[0].toString() : "");
|
||||||
|
item.put("deviceCount", row[1]);
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS update_device_record (
|
||||||
|
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
app_key VARCHAR(64) NOT NULL,
|
||||||
|
platform VARCHAR(16) NOT NULL,
|
||||||
|
device_id VARCHAR(128),
|
||||||
|
user_id VARCHAR(128),
|
||||||
|
model VARCHAR(128),
|
||||||
|
os_version VARCHAR(64),
|
||||||
|
vendor VARCHAR(32),
|
||||||
|
current_version_code INT,
|
||||||
|
current_version_name VARCHAR(32),
|
||||||
|
checked_at DATETIME(6) NOT NULL,
|
||||||
|
INDEX idx_device_record_app_checked (app_key, platform, checked_at),
|
||||||
|
INDEX idx_device_record_device (app_key, device_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@ -15,7 +15,7 @@ import java.io.IOException;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/bugcollect/v1")
|
@RequestMapping("/api/bugcollect/v1")
|
||||||
public class LogController {
|
public class LogController {
|
||||||
|
|
||||||
private final LogService logService;
|
private final LogService logService;
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户