fix: bugcollect controller 路径从 /bugcollect/v1 改为 /api/bugcollect/v1,匹配 nginx 路由

这个提交包含在:
XuqmGroup 2026-06-19 00:40:58 +08:00
父节点 62163fac7b
当前提交 8ac2a37e7f
共有 10 个文件被更改,包括 493 次插入4 次删除

查看文件

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

查看文件

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

查看文件

@ -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());
}
}
}

查看文件

@ -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;
@RestController
@RequestMapping("/bugcollect/v1")
@RequestMapping("/api/bugcollect/v1")
public class LogController {
private final LogService logService;