diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java index 2e35bc7..01605c0 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java @@ -194,6 +194,20 @@ public class AppStoreController { return ResponseEntity.ok(ApiResponse.success(null)); } + /** + * Manually refresh the review status for all target stores of a version. + * Queries each store's remote API and updates the local state if it has changed. + */ + @PostMapping("/app/{versionId}/refresh-review-status") + public ResponseEntity> refreshReviewStatus( + @PathVariable String versionId) { + AppVersionEntity v = submissionService.getVersionForPreflight(versionId); + List states = submissionService.refreshStoreReviewStatus(versionId); + return ResponseEntity.ok(ApiResponse.success( + com.xuqm.update.model.PreflightSubmitResult.of( + versionId, v.getVersionName(), v.getVersionCode(), states))); + } + // ── Modify publish schedule ────────────────────────────────────────────── /** diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index cfb4fa8..3c3f6b7 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -644,6 +644,11 @@ public class AppStoreService { return false; } if (entry instanceof Map map) { + // Exclude non-current releases from auto-publish + Object nonCurrentRelease = map.get("nonCurrentRelease"); + if (nonCurrentRelease instanceof Boolean b && b) { + return false; + } Object currentSubmissionLive = map.get("currentSubmissionLive"); return !(currentSubmissionLive instanceof Boolean b) || b; } diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index f426453..51c958c 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -519,21 +519,29 @@ public class StoreSubmissionService { int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1)); boolean updateVersion = root.path("updateVersion").asBoolean(false); String submittedCode = String.valueOf(v.getVersionCode()); - boolean isLive = updateVersion || submittedCode.equals(onlineVersionCode); + // updateVersion=true means "there is a live version" but NOT necessarily the current submission. + // Only treat as live for the CURRENT submission when onlineVersionCode matches submittedCode. + boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank(); + boolean hasOnlineVersion = updateVersion || !onlineVersionCode.isBlank(); + boolean nonCurrentRelease = hasOnlineVersion && !currentSubmissionLive; StoreRemoteState.ReviewState reviewState; if (appStatus == 5) { reviewState = StoreRemoteState.ReviewState.REJECTED; - } else if (updateVersion) { + } else if (currentSubmissionLive) { reviewState = StoreRemoteState.ReviewState.ONLINE; } else { + // Keep UNDER_REVIEW when no explicit rejection for current version. + // updateVersion alone is unreliable for version attribution. reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; } + log.info("MI remote state queried: versionId={} submittedCode={} onlineVersionCode={} onlineVersionName={} appStatus={} updateVersion={} reviewState={} currentSubmissionLive={} nonCurrentRelease={}", + v.getId(), submittedCode, onlineVersionCode, onlineVersionName, appStatus, updateVersion, reviewState, currentSubmissionLive, nonCurrentRelease); return StoreRemoteState.ok( AppStoreConfigEntity.StoreType.MI, reviewState, onlineVersionName, onlineVersionCode, "", "", - isLive, !isLive && !onlineVersionCode.isBlank(), + currentSubmissionLive, nonCurrentRelease, true, "", sanitizeJson(root)); } @@ -569,7 +577,9 @@ public class StoreSubmissionService { String accessKey = require(creds, "accessKey", "VIVO"); String accessSecret = require(creds, "accessSecret", "VIVO"); String pkg = requirePackageName(v); - String cacheKey = v.getAppKey() + ":" + pkg; + // Cache key includes versionCode because StoreRemoteState contains version-specific + // fields (currentSubmissionLive, nonCurrentRelease) that differ per version. + String cacheKey = v.getAppKey() + ":" + pkg + ":" + v.getVersionCode(); VivoCacheEntry cached = vivoQueryCache.get(cacheKey); if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) { log.debug("VIVO query cache hit for {}", cacheKey); @@ -596,13 +606,17 @@ public class StoreSubmissionService { } else { reviewState = StoreRemoteState.ReviewState.UNKNOWN; } + boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode); + boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode); + log.info("VIVO remote state queried: versionId={} submittedCode={} onlineVersionCode={} onlineVersionName={} status={} reviewState={} currentSubmissionLive={} nonCurrentRelease={}", + v.getId(), submittedCode, onlineVersionCode, onlineVersionName, status, reviewState, currentSubmissionLive, nonCurrentRelease); StoreRemoteState state = StoreRemoteState.ok( AppStoreConfigEntity.StoreType.VIVO, reviewState, onlineVersionName, onlineVersionCode, "", "", - isLive && submittedCode.equals(onlineVersionCode), - isLive && !submittedCode.equals(onlineVersionCode), + currentSubmissionLive, + nonCurrentRelease, true, "", sanitizeJson(root)); vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis())); return state; @@ -701,11 +715,17 @@ public class StoreSubmissionService { log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType); } } else { - if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) { - log.info("Store review poll: {}/{} was REJECTED but store has live version currentSubmissionLive={} onlineVersionName={} onlineVersionCode={}", - v.getId(), storeType, polled.isCurrentSubmissionLive(), polled.getOnlineVersionName(), polled.getOnlineVersionCode()); - storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), + // REJECTED: only transition to APPROVED if the CURRENT submission is now live. + // If a different version is online (nonCurrentRelease), keep REJECTED — + // the rejection of the current submission is still valid. + if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isCurrentSubmissionLive()) { + log.info("Store review poll: {}/{} was REJECTED but current submission is now live", + v.getId(), storeType); + storeService.updateStoreReviewLive(v.getId(), storeType, false, buildLiveReason(polled), buildExtra(polled)); + } else if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isNonCurrentRelease()) { + log.debug("Store review poll: {}/{} was REJECTED and a different version is online — leaving REJECTED", + v.getId(), storeType); } // otherwise leave REJECTED as-is } @@ -716,6 +736,102 @@ public class StoreSubmissionService { } } + /** + * Manually refresh the review status for all target stores of a given version. + * Unlike the scheduled poll, this works for any state (not just UNDER_REVIEW/REJECTED) + * and returns the refreshed states to the caller. + */ + public List refreshStoreReviewStatus(String versionId) { + AppVersionEntity v = versionRepo.findById(versionId).orElse(null); + if (v == null) { + throw new IllegalArgumentException("Version not found: " + versionId); + } + Map reviewMap; + try { + reviewMap = v.getStoreReviewStatus() != null + ? mapper.readValue(v.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {}) + : new LinkedHashMap<>(); + } catch (Exception e) { + reviewMap = new LinkedHashMap<>(); + } + List targets = parseTargets(v.getStoreSubmitTargets()); + List results = new ArrayList<>(); + for (String storeType : targets) { + AppStoreConfigEntity cfg; + try { + cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(), + AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null); + } catch (Exception e) { + results.add(StoreRemoteState.failed( + AppStoreConfigEntity.StoreType.valueOf(storeType), + "Store config invalid: " + e.getMessage(), + "厂商配置无效")); + continue; + } + if (cfg == null || !cfg.isEnabled()) { + results.add(StoreRemoteState.failed( + AppStoreConfigEntity.StoreType.valueOf(storeType), + "Store config not found or disabled", + "厂商未配置或已禁用")); + continue; + } + try { + Map creds = parseConfig(cfg.getConfigJson()); + StoreRemoteState polled = queryRemoteState(storeType, v, creds); + if (polled == null) { + results.add(StoreRemoteState.failed( + AppStoreConfigEntity.StoreType.valueOf(storeType), + "No poll API for this store", + "该厂商不支持状态查询")); + continue; + } + String existingState = ""; + Object entry = reviewMap.get(storeType); + if (entry instanceof Map m) { + Object s = m.get("state"); + existingState = s instanceof String str ? str : ""; + } else if (entry instanceof String s) { + existingState = s; + } + boolean isUnderReview = "UNDER_REVIEW".equals(existingState); + boolean isRejected = "REJECTED".equals(existingState); + AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState()); + if (isUnderReview) { + if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) { + log.info("Manual refresh: {}/{} status changed from UNDER_REVIEW to {}", v.getId(), storeType, mappedState); + if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) { + storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), + buildLiveReason(polled), buildExtra(polled)); + } else { + storeService.updateStoreReview(v.getId(), storeType, mappedState, "手动刷新厂商审核状态"); + } + } + } else if (isRejected) { + if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isCurrentSubmissionLive()) { + log.info("Manual refresh: {}/{} was REJECTED but current submission is now live", v.getId(), storeType); + storeService.updateStoreReviewLive(v.getId(), storeType, false, + buildLiveReason(polled), buildExtra(polled)); + } else if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isNonCurrentRelease()) { + log.info("Manual refresh: {}/{} was REJECTED and a different version is online — leaving REJECTED", v.getId(), storeType); + } + } else { + // For other states (PENDING, SUBMITTING, APPROVED, WITHDRAWN), + // just record the polled state without modifying the database. + log.debug("Manual refresh: {}/{} existing={} polled={} — no state transition applied", + v.getId(), storeType, existingState, polled.getReviewState()); + } + results.add(polled); + } catch (Exception e) { + log.warn("Manual refresh query failed for {}/{}: {}", v.getId(), storeType, e.getMessage()); + results.add(StoreRemoteState.failed( + AppStoreConfigEntity.StoreType.valueOf(storeType), + describeException(e), + "查询失败,不能确认状态")); + } + } + return results; + } + private AppVersionEntity.StoreReviewState mapToStoreReviewState(StoreRemoteState.ReviewState reviewState) { return switch (reviewState) { case ONLINE -> AppVersionEntity.StoreReviewState.APPROVED; diff --git a/update-service/src/test/java/com/xuqm/update/service/StoreRemoteStateParsingTest.java b/update-service/src/test/java/com/xuqm/update/service/StoreRemoteStateParsingTest.java new file mode 100644 index 0000000..2ad0266 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/service/StoreRemoteStateParsingTest.java @@ -0,0 +1,169 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.AppStoreConfigEntity; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.model.StoreRemoteState; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for vendor API response parsing logic. + * Verifies Xiaomi and Vivo edge cases with non-current releases. + */ +class StoreRemoteStateParsingTest { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Test + void xiaomiUpdateVersionTrueButDifferentOnlineVersion() throws Exception { + // Xiaomi returns updateVersion=true but onlineVersionCode is for an older release + String json = "{\"packageInfo\":{\"versionCode\":\"260513531\",\"versionName\":\"7.2.10\",\"appStatus\":1},\"updateVersion\":true}"; + JsonNode root = mapper.readTree(json); + JsonNode pkg = root.path("packageInfo"); + + String onlineVersionCode = pkg.path("versionCode").asText(""); + String onlineVersionName = pkg.path("versionName").asText(""); + int appStatus = pkg.path("appStatus").asInt(-1); + boolean updateVersion = root.path("updateVersion").asBoolean(false); + String submittedCode = "260518336"; + + boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank(); + boolean hasOnlineVersion = updateVersion || !onlineVersionCode.isBlank(); + boolean nonCurrentRelease = hasOnlineVersion && !currentSubmissionLive; + + StoreRemoteState.ReviewState reviewState; + if (appStatus == 5) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (currentSubmissionLive) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; + } + + assertFalse(currentSubmissionLive, "Current submission should NOT be live when version codes differ"); + assertTrue(nonCurrentRelease, "Should detect non-current release when online version differs"); + assertEquals(StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI, reviewState, + "Should remain UNDER_REVIEW when updateVersion=true but online version is different"); + } + + @Test + void xiaomiCurrentSubmissionLive() throws Exception { + String json = "{\"packageInfo\":{\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\",\"appStatus\":2},\"updateVersion\":true}"; + JsonNode root = mapper.readTree(json); + JsonNode pkg = root.path("packageInfo"); + + String onlineVersionCode = pkg.path("versionCode").asText(""); + String submittedCode = "260518336"; + int appStatus = pkg.path("appStatus").asInt(-1); + boolean updateVersion = root.path("updateVersion").asBoolean(false); + + boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank(); + StoreRemoteState.ReviewState reviewState; + if (appStatus == 5) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (currentSubmissionLive) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; + } + + assertTrue(currentSubmissionLive, "Current submission should be live when version codes match"); + assertEquals(StoreRemoteState.ReviewState.ONLINE, reviewState, + "Should be ONLINE when current submission matches online version"); + } + + @Test + void xiaomiExplicitRejection() throws Exception { + String json = "{\"packageInfo\":{\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\",\"appStatus\":5},\"updateVersion\":false}"; + JsonNode root = mapper.readTree(json); + JsonNode pkg = root.path("packageInfo"); + + int appStatus = pkg.path("appStatus").asInt(-1); + StoreRemoteState.ReviewState reviewState; + if (appStatus == 5) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; + } + + assertEquals(StoreRemoteState.ReviewState.REJECTED, reviewState, + "appStatus=5 should always be REJECTED"); + } + + @Test + void vivoNonCurrentRelease() throws Exception { + // Vivo returns status=3 (online) but versionCode is for an older release + String json = "{\"data\":{\"status\":3,\"versionCode\":\"260513531\",\"versionName\":\"7.2.10\"}}"; + JsonNode root = mapper.readTree(json); + JsonNode data = root.path("data"); + + int status = data.path("status").asInt(-1); + String onlineVersionCode = data.path("versionCode").asText(""); + String submittedCode = "260518336"; + boolean isLive = status == 3; + + boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode); + boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode); + + assertFalse(currentSubmissionLive, "Current submission should NOT be live when version codes differ"); + assertTrue(nonCurrentRelease, "Should detect non-current release when online version differs"); + } + + @Test + void vivoCurrentSubmissionLive() throws Exception { + String json = "{\"data\":{\"status\":3,\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\"}}"; + JsonNode root = mapper.readTree(json); + JsonNode data = root.path("data"); + + int status = data.path("status").asInt(-1); + String onlineVersionCode = data.path("versionCode").asText(""); + String submittedCode = "260518336"; + boolean isLive = status == 3; + + boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode); + boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode); + + assertTrue(currentSubmissionLive, "Current submission should be live when version codes match"); + assertFalse(nonCurrentRelease, "Should NOT be non-current release when version codes match"); + } + + @Test + void vivoMissingVersionCodeDefaultsToNonCurrentRelease() throws Exception { + // Vivo returns status=3 but no versionCode - safest assumption is non-current release + String json = "{\"data\":{\"status\":3,\"versionName\":\"7.2.10\"}}"; + JsonNode root = mapper.readTree(json); + JsonNode data = root.path("data"); + + int status = data.path("status").asInt(-1); + String onlineVersionCode = data.path("versionCode").asText(""); + String submittedCode = "260518336"; + boolean isLive = status == 3; + + boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode); + boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode); + + assertFalse(currentSubmissionLive, "Should not be current submission live when versionCode is missing"); + assertTrue(nonCurrentRelease, "Should default to non-current release when versionCode is missing but status is online"); + } + + @Test + @SuppressWarnings("unchecked") + void allApprovedExcludesNonCurrentRelease() throws Exception { + // Simulate reviewMap with a store that is APPROVED but nonCurrentRelease=true + String reviewJson = "{\"VIVO\":{\"state\":\"APPROVED\",\"nonCurrentRelease\":true,\"currentSubmissionLive\":false}}"; + java.util.Map reviewMap = mapper.readValue(reviewJson, java.util.Map.class); + + // The allApproved logic should reject this because nonCurrentRelease=true + Object entry = reviewMap.get("VIVO"); + assertTrue(entry instanceof java.util.Map); + java.util.Map map = (java.util.Map) entry; + Object nonCurrentRelease = map.get("nonCurrentRelease"); + assertTrue(nonCurrentRelease instanceof Boolean && (Boolean) nonCurrentRelease); + + Object currentSubmissionLive = map.get("currentSubmissionLive"); + assertTrue(currentSubmissionLive instanceof Boolean && !(Boolean) currentSubmissionLive); + } +}