package com.xuqm.update.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.model.AppPackageInspectResult; import com.xuqm.update.service.UpdateOperationLogService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.time.LocalDateTime; import java.time.format.DateTimeParseException; 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.PublishConfigService; import com.xuqm.update.service.AppStoreService; import com.xuqm.update.service.ImPushUserClient; @RestController @RequestMapping("/api/v1/updates") public class AppVersionController { private static final Logger log = LoggerFactory.getLogger(AppVersionController.class); private final AppVersionRepository versionRepository; private final UpdateAssetService updateAssetService; private final PublishConfigService publishConfigService; private final AppStoreService appStoreService; private final UpdateOperationLogService operationLogService; private final ImPushUserClient imPushUserClient; public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, AppStoreService appStoreService, UpdateOperationLogService operationLogService, ImPushUserClient imPushUserClient) { this.versionRepository = versionRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.appStoreService = appStoreService; this.operationLogService = operationLogService; this.imPushUserClient = imPushUserClient; } @GetMapping("/app/check") public ResponseEntity>> checkUpdate( @RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform, @RequestParam int currentVersionCode, @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))); } Optional latest = versionRepository .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); Optional forcedHigher = versionRepository .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); if (latest.isEmpty()) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); } AppVersionEntity v = latest.get(); // Gray release filtering if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) { boolean inGray = isInGrayRelease(v, userId); if (!inGray) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); } } String appStoreJumpUrl = hasText(v.getAppStoreUrl()) ? v.getAppStoreUrl() : appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); String harmonyJumpUrl = hasText(v.getMarketUrl()) ? v.getMarketUrl() : appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", true, "versionName", v.getVersionName(), "versionCode", v.getVersionCode(), "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", "forceUpdate", forcedHigher.isPresent(), "appStoreUrl", appStoreJumpUrl, "marketUrl", harmonyJumpUrl ))); } @PostMapping("/app/upload") public ResponseEntity> upload( @RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform, @RequestParam(required = false) String versionName, @RequestParam(required = false) Integer versionCode, @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, @RequestParam(required = false) String apkUrl, @RequestParam(required = false) MultipartFile apkFile, @RequestParam(required = false) String scheduledPublishAt, @RequestParam(required = false) String webhookUrl, @RequestParam(required = false) String storeSubmitTargets, @RequestParam(defaultValue = "false") boolean autoPublishAfterReview, @RequestParam(defaultValue = "false") boolean publishImmediately, @RequestParam(required = false) String packageName, @RequestParam(required = false) String expectedPackageName, @RequestParam(required = false) String appStoreUrl, @RequestParam(required = false) String marketUrl) throws Exception { AppPackageInspectResult inspected = null; try { inspected = hasText(apkUrl) ? updateAssetService.inspectAppPackage(apkUrl) : (apkFile != null && !apkFile.isEmpty() ? updateAssetService.inspectAppPackage(apkFile) : null); } catch (Exception ex) { log.warn("Unable to inspect upload package for appKey={}, platform={}, source={}, fallback to manual version fields: {}", appKey, platform, hasText(apkUrl) ? apkUrl : (apkFile != null ? apkFile.getOriginalFilename() : null), ex.getMessage()); } String resolvedVersionName = hasText(versionName) ? versionName : (inspected != null ? inspected.versionName() : null); Integer resolvedVersionCode = versionCode != null ? versionCode : (inspected != null ? inspected.versionCode() : null); String resolvedPackageName = hasText(packageName) ? packageName : (inspected != null ? inspected.packageName() : null); if (!hasText(resolvedVersionName) || resolvedVersionCode == null) { throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package"); } if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) { throw new IllegalArgumentException("packageName does not match current app packageName"); } if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) { throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases"); } // Duplicate version check if (hasText(resolvedPackageName)) { java.util.Optional existing = versionRepository .findByAppKeyAndPlatformAndPackageNameAndVersionCode( appKey, platform, resolvedPackageName, resolvedVersionCode); if (existing.isPresent()) { throw new IllegalArgumentException( "版本已存在,不能重复上传:" + resolvedVersionName + " · " + resolvedVersionCode); } // Check if same versionCode exists with any store already live java.util.List sameCodeVersions = versionRepository .findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus( appKey, platform, resolvedPackageName, resolvedVersionCode, AppVersionEntity.PublishStatus.PUBLISHED); if (!sameCodeVersions.isEmpty() && inspected != null) { // If a published version with same code exists, compare APK md5 // This is a conservative check; we can't easily compare md5 here without downloading, // so we just block if same versionCode was ever published throw new IllegalArgumentException( "应用商店已上线相同版本号,不允许重复上传:" + resolvedVersionName + " · " + resolvedVersionCode); } } AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppKey(appKey); entity.setPlatform(platform); entity.setVersionName(resolvedVersionName); entity.setVersionCode(resolvedVersionCode); entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID ? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile)) : null); entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); entity.setStoreSubmitMode("MANUAL"); entity.setStoreSubmitScheduledAt(null); entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { try { entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); } catch (DateTimeParseException e) { throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)"); } } entity.setWebhookUrl(webhookUrl); entity.setStoreSubmitTargets(storeSubmitTargets); entity.setAutoPublishAfterReview(autoPublishAfterReview); entity.setPackageName(resolvedPackageName); entity.setAppStoreUrl(hasText(appStoreUrl) ? appStoreUrl : (platform == AppVersionEntity.Platform.IOS ? appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE) : null)); entity.setMarketUrl(hasText(marketUrl) ? marketUrl : (platform == AppVersionEntity.Platform.HARMONY ? appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP) : null)); if (publishImmediately) { entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setGrayEnabled(false); entity.setGrayPercent(0); } AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), "APP_VERSION", saved.getId(), "UPLOAD", null, Map.of( "platform", saved.getPlatform().name(), "versionName", saved.getVersionName(), "versionCode", saved.getVersionCode(), "publishImmediately", publishImmediately, "forceUpdate", saved.isForceUpdate(), "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() )); return ResponseEntity.ok(ApiResponse.success(saved)); } @RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST}) public ResponseEntity> inspect( @RequestParam(required = false) String apkUrl, @RequestParam(required = false) MultipartFile apkFile) throws Exception { if (hasText(apkUrl)) { return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkUrl))); } return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile))); } @PostMapping("/app/{id}/publish") public ResponseEntity> publish( @PathVariable String id, @RequestBody(required = false) Map body) { AppVersionEntity entity = versionRepository.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; boolean forceUpdate = body != null && body.get("forceUpdate") != null ? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate(); AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus(); entity.setForceUpdate(forceUpdate); if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setScheduledPublishAt(null); } else { entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { try { entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); } catch (DateTimeParseException e) { throw new IllegalArgumentException("scheduledPublishAt 格式无效,应为 ISO-8601(如 2026-05-01T10:00:00)"); } } } entity.setGrayEnabled(false); entity.setGrayPercent(0); entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); entity.setGrayCallbackUrl(null); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), "APP_VERSION", saved.getId(), publishAction(previousStatus, saved.getPublishStatus(), publishImmediately), null, Map.of( "versionName", saved.getVersionName(), "versionCode", saved.getVersionCode(), "publishImmediately", publishImmediately, "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(), "forceUpdate", saved.isForceUpdate() )); return ResponseEntity.ok(ApiResponse.success(saved)); } @PostMapping("/app/{id}/unpublish") public ResponseEntity> unpublish( @PathVariable String id, @RequestBody(required = false) Map body) { AppVersionEntity entity = versionRepository.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(AppVersionEntity.PublishStatus.DEPRECATED); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), "APP_VERSION", saved.getId(), "UNPUBLISH", reason, Map.of( "versionName", saved.getVersionName(), "versionCode", saved.getVersionCode() )); return ResponseEntity.ok(ApiResponse.success(saved)); } @DeleteMapping("/app/{id}") public ResponseEntity> deleteVersion(@PathVariable String id) { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); if (entity.getPublishStatus() != AppVersionEntity.PublishStatus.DRAFT) { throw new IllegalArgumentException("已发布或已下架的版本不允许删除,请先下架"); } // Check if any store is in active review if (hasActiveStoreReview(entity)) { throw new IllegalArgumentException("当前版本有厂商正在审核中,不允许删除"); } versionRepository.delete(entity); operationLogService.record( entity.getAppKey(), "APP_VERSION", id, "DELETE", null, Map.of( "versionName", entity.getVersionName(), "versionCode", entity.getVersionCode() )); return ResponseEntity.ok(ApiResponse.success(null)); } private boolean hasActiveStoreReview(AppVersionEntity entity) { if (entity.getStoreReviewStatus() == null || entity.getStoreReviewStatus().isBlank()) { return false; } try { com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); @SuppressWarnings("unchecked") java.util.Map> reviewMap = mapper.readValue(entity.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {}); for (java.util.Map info : reviewMap.values()) { String state = info.get("state") instanceof String s ? s : ""; if ("SUBMITTING".equals(state) || "UNDER_REVIEW".equals(state)) { return true; } } } catch (Exception e) { return false; } return false; } @PostMapping("/app/{id}/gray") public ResponseEntity> gray( @PathVariable String id, @RequestBody Map body) throws Exception { AppVersionEntity entity = versionRepository.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(AppVersionEntity.GrayMode.PERCENT); entity.setGrayPercent(0); entity.setGrayMemberIds(null); entity.setGrayCallbackUrl(null); } else { AppVersionEntity.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(AppVersionEntity.PublishStatus.PUBLISHED); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), "APP_VERSION", saved.getId(), "GRAY_UPDATE", null, Map.of( "enabled", enabled, "grayMode", saved.getGrayMode().name(), "grayPercent", saved.getGrayPercent(), "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() )); return ResponseEntity.ok(ApiResponse.success(saved)); } private AppVersionEntity.GrayMode parseGrayMode(Object raw) { if (raw == null) { return AppVersionEntity.GrayMode.PERCENT; } try { return AppVersionEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase()); } catch (IllegalArgumentException e) { return AppVersionEntity.GrayMode.PERCENT; } } private boolean isInGrayRelease(AppVersionEntity v, String userId) { return switch (v.getGrayMode()) { case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent(); case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId); case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId); case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId); }; } private boolean resolveCallbackGray(AppVersionEntity v, String userId) { if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) { return false; } try { List memberIds = publishConfigService.resolveGrayMembersFromUrl( v.getGrayCallbackUrl(), v.getAppKey(), userId); return memberIds.contains(userId); } catch (Exception e) { log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage()); return false; } } @PatchMapping("/app/{id}/changelog") public ResponseEntity> updateChangeLog( @PathVariable String id, @RequestBody Map body) { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); String oldChangeLog = entity.getChangeLog(); Object raw = body.get("changeLog"); String newChangeLog = raw != null && !raw.toString().isBlank() ? raw.toString().trim() : null; entity.setChangeLog(newChangeLog); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), "APP_VERSION", saved.getId(), "CHANGELOG_UPDATE", null, Map.of( "versionName", saved.getVersionName(), "versionCode", saved.getVersionCode(), "before", oldChangeLog != null ? oldChangeLog : "", "after", newChangeLog != null ? newChangeLog : "" )); return ResponseEntity.ok(ApiResponse.success(saved)); } @GetMapping("/app/list") public ResponseEntity>> list( @RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) { return ResponseEntity.ok(ApiResponse.success( versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform))); } private String publishAction(AppVersionEntity.PublishStatus previousStatus, AppVersionEntity.PublishStatus currentStatus, boolean publishImmediately) { if (!publishImmediately) { return "SCHEDULE_PUBLISH"; } if (previousStatus == AppVersionEntity.PublishStatus.PUBLISHED) { return "UPDATE_FORCE"; } if (previousStatus == AppVersionEntity.PublishStatus.DEPRECATED) { return "REPUBLISH"; } return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT"; } private boolean hasText(String value) { return value != null && !value.isBlank(); } 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(); } private String toJson(List values) { try { return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values); } catch (Exception e) { return "[]"; } } }