XuqmGroup-Server/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java
XuqmGroup 3e2db6441e feat(update): 添加 API Key 管理和 WebSocket 实时通知功能
- 新增 API Key 管理功能,支持外部工具认证调用平台 API
- 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端
- 添加 APK 文件哈希校验,支持已下载检测和直接安装
- 支持外部 APK 上传使用 API Key 认证
- 优化私有化部署自动注入 nginx WebSocket 代理配置
- 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听
2026-06-11 12:25:16 +08:00

399 行
18 KiB
Java

package com.xuqm.update.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundleInspectResult;
import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.UpdateOperationLogService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.GrayMemberService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1/rn")
public class RnBundleController {
private static final Logger log = LoggerFactory.getLogger(RnBundleController.class);
private final RnBundleRepository bundleRepository;
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient;
private final GrayMemberService grayMemberService;
private final ObjectMapper objectMapper;
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl;
public RnBundleController(RnBundleRepository bundleRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient,
GrayMemberService grayMemberService,
ObjectMapper objectMapper) {
this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient;
this.grayMemberService = grayMemberService;
this.objectMapper = objectMapper;
}
@GetMapping("/update/check")
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam String appKey,
@RequestParam String moduleId,
@RequestParam String platform,
@RequestParam String currentVersion,
@RequestParam(required = false) String packageName,
@RequestParam(required = false) String userId) {
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase());
Optional<RnBundleEntity> latest = bundleRepository
.findTopByAppKeyAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc(
appKey, moduleId, p, RnBundleEntity.PublishStatus.PUBLISHED);
if (latest.isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
RnBundleEntity b = latest.get();
boolean needsUpdate = !b.getVersion().equals(currentVersion);
if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) {
if (!isInGrayRelease(b, userId)) {
needsUpdate = false;
}
}
return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", needsUpdate,
"bundleVersion", parseBundleVersion(b.getVersion()),
"latestVersion", b.getVersion(),
"downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appKey + "/" + platform.toLowerCase() + "/" + moduleId,
"md5", b.getMd5(),
"minCommonVersion", b.getMinCommonVersion() != null ? b.getMinCommonVersion() : "0.0.0",
"note", b.getNote() != null ? b.getNote() : "",
"packageName", b.getPackageName() != null ? b.getPackageName() : "",
"packageMatched", packageName == null || packageName.isBlank() || b.getPackageName() == null || b.getPackageName().isBlank()
|| b.getPackageName().equals(packageName)
)));
}
@PostMapping("/upload")
public ResponseEntity<ApiResponse<RnBundleEntity>> upload(
@RequestParam String appKey,
@RequestParam(required = false) String moduleId,
@RequestParam(required = false) RnBundleEntity.Platform platform,
@RequestParam(required = false) String version,
@RequestParam(required = false) String minCommonVersion,
@RequestParam(required = false) String packageName,
@RequestParam(required = false) String note,
@RequestParam MultipartFile bundle) throws Exception {
RnBundleInspectResult inspected = updateAssetService.inspectRnBundle(bundle);
String resolvedModuleId = hasText(moduleId) ? moduleId : inspected.moduleId();
String resolvedVersion = hasText(version) ? version : inspected.version();
String resolvedMinCommonVersion = hasText(minCommonVersion) ? minCommonVersion : inspected.minCommonVersion();
String resolvedPackageName = hasText(packageName) ? packageName : inspected.packageName();
RnBundleEntity.Platform resolvedPlatform = platform != null ? platform : parsePlatform(inspected.platform());
if (!hasText(resolvedModuleId) || !hasText(resolvedVersion) || resolvedPlatform == null) {
throw new IllegalArgumentException("moduleId, version and platform are required or must be readable from the bundle name");
}
UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle(
appKey, resolvedPlatform.name(), resolvedModuleId, bundle);
RnBundleEntity entity = new RnBundleEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
entity.setModuleId(resolvedModuleId);
entity.setPlatform(resolvedPlatform);
entity.setVersion(resolvedVersion);
entity.setBundleUrl(stored.bundlePath());
entity.setMd5(stored.md5());
entity.setMinCommonVersion(resolvedMinCommonVersion);
entity.setPackageName(resolvedPackageName);
entity.setNote(note);
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setPublishMode("MANUAL");
entity.setScheduledPublishAt(null);
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setCreatedAt(LocalDateTime.now());
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"RN_BUNDLE",
saved.getId(),
"UPLOAD",
null,
Map.of(
"moduleId", saved.getModuleId(),
"platform", saved.getPlatform().name(),
"version", saved.getVersion(),
"minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(),
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST})
public ResponseEntity<ApiResponse<RnBundleInspectResult>> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception {
return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle)));
}
@GetMapping("/list")
public ResponseEntity<ApiResponse<List<RnBundleEntity>>> list(
@RequestParam String appKey,
@RequestParam(required = false) String moduleId,
@RequestParam(required = false) String platform) {
List<RnBundleEntity> result;
if (moduleId != null && platform != null) {
RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase());
result = bundleRepository.findByAppKeyAndModuleIdAndPlatformOrderByCreatedAtDesc(appKey, moduleId, p);
} else if (moduleId != null) {
result = bundleRepository.findByAppKeyAndModuleIdOrderByCreatedAtDesc(appKey, moduleId);
} else {
result = bundleRepository.findByAppKeyOrderByCreatedAtDesc(appKey);
}
return ResponseEntity.ok(ApiResponse.success(result));
}
@PostMapping("/{id}/publish")
public ResponseEntity<ApiResponse<RnBundleEntity>> publish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately"));
String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null
? body.get("scheduledPublishAt").toString() : null;
entity.setPublishMode(publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())
? "NOW" : "SCHEDULED");
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
entity.setScheduledPublishAt(null);
} else {
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
}
}
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"RN_BUNDLE",
saved.getId(),
publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) ? "PUBLISH" : "SCHEDULE_PUBLISH",
null,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion(),
"publishMode", saved.getPublishMode(),
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/{id}/unpublish")
public ResponseEntity<ApiResponse<RnBundleEntity>> unpublish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
if (reason.isBlank()) {
throw new IllegalArgumentException("unpublish reason is required");
}
entity.setPublishStatus(RnBundleEntity.PublishStatus.DEPRECATED);
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"RN_BUNDLE",
saved.getId(),
"UNPUBLISH",
reason,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/{id}/gray")
public ResponseEntity<ApiResponse<RnBundleEntity>> gray(
@PathVariable String id,
@RequestBody Map<String, Object> body) throws Exception {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
entity.setGrayEnabled(enabled);
if (!enabled) {
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayPercent(0);
entity.setGrayMemberIds(null);
entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
} else {
RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode);
switch (grayMode) {
case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
}
case MEMBERS -> {
entity.setGrayPercent(0);
List<String> groupNames = extractMemberIds(body.get("groupNames"));
List<String> extraIds = extractMemberIds(body.get("extraMemberIds"));
entity.setGrayGroupNames(toJson(groupNames));
entity.setExtraMemberIds(toJson(extraIds));
String resolved = grayMemberService.resolveMemberIds(
entity.getAppKey(), groupNames, extraIds);
entity.setGrayMemberIds(resolved);
}
}
}
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
"RN_BUNDLE",
saved.getId(),
"GRAY_UPDATE",
null,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion(),
"grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(),
"memberCount", memberCount
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
private RnBundleEntity.GrayMode parseGrayMode(Object raw) {
if (raw == null) {
return RnBundleEntity.GrayMode.PERCENT;
}
try {
return RnBundleEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase());
} catch (IllegalArgumentException e) {
return RnBundleEntity.GrayMode.PERCENT;
}
}
private boolean isInGrayRelease(RnBundleEntity b, String userId) {
return switch (b.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent();
case MEMBERS -> {
if (b.getGrayMemberIds() == null) yield false;
try {
List<String> ids = objectMapper.readValue(b.getGrayMemberIds(),
new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
yield ids.contains(userId);
} catch (Exception e) {
yield false;
}
}
};
}
private String resolvePublicBaseUrl() {
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String suffix = "/api/v1/updates";
if (normalized.endsWith(suffix)) {
return normalized.substring(0, normalized.length() - suffix.length());
}
return normalized;
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private RnBundleEntity.Platform parsePlatform(String platform) {
if (!hasText(platform)) {
return null;
}
try {
return RnBundleEntity.Platform.valueOf(platform.toUpperCase());
} catch (Exception e) {
return null;
}
}
private int parseBundleVersion(String version) {
if (!hasText(version)) {
return 0;
}
try {
return Integer.parseInt(version.trim());
} catch (Exception ignored) {
return 0;
}
}
private String toJson(List<String> values) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values);
} catch (Exception e) {
return "[]";
}
}
private List<String> extractMemberIds(Object raw) {
if (raw == null) {
return List.of();
}
if (raw instanceof List<?> list) {
return list.stream()
.map(String::valueOf)
.filter(this::hasText)
.map(String::trim)
.toList();
}
if (raw instanceof String s) {
if (!hasText(s)) {
return List.of();
}
try {
return java.util.Arrays.stream(s.split(","))
.map(String::trim)
.filter(this::hasText)
.toList();
} catch (Exception ignored) {
return List.of();
}
}
return List.of();
}
}