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; @RestController @RequestMapping("/api/v1/rn") public class RnBundleController { private final RnBundleRepository bundleRepository; private final UpdateAssetService updateAssetService; private final PublishConfigService publishConfigService; private final UpdateOperationLogService operationLogService; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, UpdateOperationLogService operationLogService) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.operationLogService = operationLogService; } @GetMapping("/update/check") public ResponseEntity>> checkUpdate( @RequestParam String appId, @RequestParam String moduleId, @RequestParam String platform, @RequestParam String currentVersion, @RequestParam(required = false) String packageName) { RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase()); Optional latest = bundleRepository .findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( appId, 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); return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", needsUpdate, "bundleVersion", parseBundleVersion(b.getVersion()), "latestVersion", b.getVersion(), "downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + 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 appId, @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( appId, resolvedPlatform.name(), resolvedModuleId, bundle); RnBundleEntity entity = new RnBundleEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); 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("PERCENT"); entity.setGrayMemberIds(null); entity.setCreatedAt(LocalDateTime.now()); RnBundleEntity saved = bundleRepository.save(entity); operationLogService.record( saved.getAppId(), "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 appId, @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.findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc(appId, moduleId, p); } else if (moduleId != null) { result = bundleRepository.findByAppIdAndModuleIdOrderByCreatedAtDesc(appId, moduleId); } else { result = bundleRepository.findByAppIdOrderByCreatedAtDesc(appId); } 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("PERCENT"); entity.setGrayMemberIds(null); RnBundleEntity saved = bundleRepository.save(entity); operationLogService.record( saved.getAppId(), "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.getAppId(), "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(); boolean enabled = Boolean.TRUE.equals(body.get("enabled")); String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase(); entity.setGrayEnabled(enabled); if (!enabled) { entity.setGrayPercent(0); entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); } else if ("MEMBERS".equals(grayMode)) { List memberIds = extractMemberIds(body.get("memberIds")); String selectionSource = body.get("selectionSource") == null ? "LOCAL" : body.get("selectionSource").toString().trim().toUpperCase(); if (memberIds.isEmpty() && "CALLBACK".equals(selectionSource)) { memberIds = publishConfigService.resolveGrayMembers(entity.getAppId(), body); } entity.setGrayMode("MEMBERS"); entity.setGrayMemberIds(toJson(memberIds)); entity.setGrayPercent(0); } else { entity.setGrayMode("PERCENT"); entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); entity.setGrayMemberIds(null); } entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); RnBundleEntity saved = bundleRepository.save(entity); operationLogService.record( saved.getAppId(), "RN_BUNDLE", saved.getId(), "GRAY_UPDATE", null, Map.of( "moduleId", saved.getModuleId(), "version", saved.getVersion(), "grayMode", saved.getGrayMode(), "grayPercent", saved.getGrayPercent(), "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() )); return ResponseEntity.ok(ApiResponse.success(saved)); } 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(); } }