diff --git a/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java b/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java new file mode 100644 index 0000000..cec74c3 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java @@ -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 签名。 + *
+ * 请求头: + *
+ * 签名载荷格式:{@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);
+ }
+}
diff --git a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java
index 3b52c93..72a635d 100644
--- a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java
+++ b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java
@@ -72,7 +72,8 @@ public final class ConfigFileCrypto {
text(node, "iosBundleId"),
text(node, "harmonyBundleName"),
text(node, "baseUrl"),
- text(node, "serverUrl"));
+ text(node, "serverUrl"),
+ text(node, "signingKey"));
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
@@ -105,7 +106,8 @@ public final class ConfigFileCrypto {
String iosBundleId,
String harmonyBundleName,
String baseUrl,
- String serverUrl) {
+ String serverUrl,
+ String signingKey) {
public boolean matchesPackageName(String candidate) {
if (candidate == null || candidate.isBlank()) return false;
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java
index d354d74..b167103 100644
--- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java
+++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java
@@ -195,6 +195,7 @@ public class AppService {
payload.put("harmonyBundleName", app.getHarmonyBundleName());
}
payload.put("serverUrl", normalizeBaseUrl(sdkPlatformPublicBaseUrl));
+ payload.put("signingKey", app.getAppSecret());
payload.put("issuedAt", java.time.Instant.now().toString());
try {
return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
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 bfcc80d..93672c8 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
@@ -2,7 +2,9 @@ package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppVersionEntity;
+import com.xuqm.update.entity.DeviceRecordEntity;
import com.xuqm.update.repository.AppVersionRepository;
+import com.xuqm.update.repository.DeviceRecordRepository;
import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger;
@@ -34,6 +36,7 @@ public class AppVersionController {
private static final Logger log = LoggerFactory.getLogger(AppVersionController.class);
private final AppVersionRepository versionRepository;
+ private final DeviceRecordRepository deviceRecordRepository;
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final AppStoreService appStoreService;
@@ -45,6 +48,7 @@ public class AppVersionController {
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
public AppVersionController(AppVersionRepository versionRepository,
+ DeviceRecordRepository deviceRecordRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
AppStoreService appStoreService,
@@ -55,6 +59,7 @@ public class AppVersionController {
GrayMemberService grayMemberService,
com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
this.versionRepository = versionRepository;
+ this.deviceRecordRepository = deviceRecordRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
@@ -72,9 +77,16 @@ public class AppVersionController {
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode,
@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);
+
+ // 异步记录设备信息(不影响响应性能)
+ recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode);
boolean serviceActivated = tenantClient.isUpdateServiceEnabled(resolvedAppKey, platform.name());
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
@@ -589,4 +601,32 @@ public class AppVersionController {
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());
+ }
+ }
}
diff --git a/update-service/src/main/java/com/xuqm/update/controller/UpdateStatisticsController.java b/update-service/src/main/java/com/xuqm/update/controller/UpdateStatisticsController.java
new file mode 100644
index 0000000..ee26362
--- /dev/null
+++ b/update-service/src/main/java/com/xuqm/update/controller/UpdateStatisticsController.java
@@ -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