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 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; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, UpdateOperationLogService operationLogService, ImPushUserClient imPushUserClient) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.operationLogService = operationLogService; this.imPushUserClient = imPushUserClient; } @GetMapping("/update/check") public ResponseEntity>> 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 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> 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> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception { return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle))); } @GetMapping("/list") public ResponseEntity>> list( @RequestParam String appKey, @RequestParam(required = false) String moduleId, @RequestParam(required = false) String platform) { List 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> publish( @PathVariable String id, @RequestBody(required = false) Map 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.setGrayCallbackUrl(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> unpublish( @PathVariable String id, @RequestBody(required = false) Map 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> gray( @PathVariable String id, @RequestBody Map 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.setGrayCallbackUrl(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.setGrayCallbackUrl(null); } case IM_PUSH_USERS -> { entity.setGrayPercent(0); entity.setGrayMemberIds(null); entity.setGrayCallbackUrl(null); } case CUSTOMER_SYNC -> { List memberIds = extractMemberIds(body.get("memberIds")); if (memberIds.isEmpty()) { memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); } entity.setGrayMemberIds(toJson(memberIds)); entity.setGrayPercent(0); entity.setGrayCallbackUrl(null); } case CUSTOMER_CALLBACK -> { String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; if (callbackUrl == null || callbackUrl.isBlank()) { throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode"); } entity.setGrayCallbackUrl(callbackUrl); entity.setGrayMemberIds(null); entity.setGrayPercent(0); } } } 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", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() )); 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 IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(b.getAppKey(), userId); case CUSTOMER_SYNC -> b.getGrayMemberIds() != null && b.getGrayMemberIds().contains(userId); case CUSTOMER_CALLBACK -> resolveRnCallbackGray(b, userId); }; } private boolean resolveRnCallbackGray(RnBundleEntity b, String userId) { if (b.getGrayCallbackUrl() == null || b.getGrayCallbackUrl().isBlank()) { return false; } try { List memberIds = publishConfigService.resolveGrayMembersFromUrl( b.getGrayCallbackUrl(), b.getAppKey(), userId); return memberIds.contains(userId); } catch (Exception e) { log.warn("RN gray callback failed for appKey={} bundleId={}: {}", b.getAppKey(), b.getId(), e.getMessage()); return 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 values) { try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values); } catch (Exception e) { return "[]"; } } private List 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(); } }