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 396785c..2e35bc7 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 @@ -134,6 +134,22 @@ public class AppStoreController { return ResponseEntity.ok(ApiResponse.success(v)); } + // ── Preflight check before submission ──────────────────────────────────── + + /** + * Query remote state of all target stores before submitting. + * Returns per-store review state, online version, and whether submission is allowed. + */ + @PostMapping("/app/{versionId}/preflight-submit") + public ResponseEntity> preflightSubmit( + @PathVariable String versionId) { + AppVersionEntity v = submissionService.getVersionForPreflight(versionId); + List states = submissionService.preflightStoreSubmission(versionId); + return ResponseEntity.ok(ApiResponse.success( + com.xuqm.update.model.PreflightSubmitResult.of( + versionId, v.getVersionName(), v.getVersionCode(), states))); + } + // ── Review status update (from store webhook or manual) ────────────────── /** diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index d85cab8..d7f1454 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -142,6 +142,28 @@ public class AppVersionController { 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); @@ -284,6 +306,51 @@ public class AppVersionController { 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, diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index d5a7082..64183a1 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -109,6 +109,19 @@ public class AppVersionEntity { @Column(length = 512) private String grayCallbackUrl; + /** + * Pending publish plan to apply after all stores approve the current submission. + * IMMEDIATE or SCHEDULED. Null means no pending plan. + */ + @Column(length = 16) + private String pendingStorePublishType; + + /** When pendingStorePublishType is SCHEDULED, the scheduled publish time. */ + private LocalDateTime pendingStorePublishScheduledAt; + + /** Last time the pending publish plan was updated. */ + private LocalDateTime pendingStorePublishUpdatedAt; + /** App package name / bundle identifier, e.g. com.example.myapp */ @Column(length = 256) private String packageName; @@ -190,4 +203,13 @@ public class AppVersionEntity { public String getGrayCallbackUrl() { return grayCallbackUrl; } public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } + + public String getPendingStorePublishType() { return pendingStorePublishType; } + public void setPendingStorePublishType(String pendingStorePublishType) { this.pendingStorePublishType = pendingStorePublishType; } + + public LocalDateTime getPendingStorePublishScheduledAt() { return pendingStorePublishScheduledAt; } + public void setPendingStorePublishScheduledAt(LocalDateTime pendingStorePublishScheduledAt) { this.pendingStorePublishScheduledAt = pendingStorePublishScheduledAt; } + + public LocalDateTime getPendingStorePublishUpdatedAt() { return pendingStorePublishUpdatedAt; } + public void setPendingStorePublishUpdatedAt(LocalDateTime pendingStorePublishUpdatedAt) { this.pendingStorePublishUpdatedAt = pendingStorePublishUpdatedAt; } } diff --git a/update-service/src/main/java/com/xuqm/update/model/PreflightSubmitResult.java b/update-service/src/main/java/com/xuqm/update/model/PreflightSubmitResult.java new file mode 100644 index 0000000..8cc929e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/PreflightSubmitResult.java @@ -0,0 +1,35 @@ +package com.xuqm.update.model; + +import java.util.List; + +/** + * Result of preflight check before submitting to app stores. + */ +public class PreflightSubmitResult { + + private String versionId; + private String versionName; + private int versionCode; + private List stores; + + public static PreflightSubmitResult of(String versionId, String versionName, int versionCode, List stores) { + PreflightSubmitResult r = new PreflightSubmitResult(); + r.versionId = versionId; + r.versionName = versionName; + r.versionCode = versionCode; + r.stores = stores; + return r; + } + + public String getVersionId() { return versionId; } + public void setVersionId(String versionId) { this.versionId = versionId; } + + public String getVersionName() { return versionName; } + public void setVersionName(String versionName) { this.versionName = versionName; } + + public int getVersionCode() { return versionCode; } + public void setVersionCode(int versionCode) { this.versionCode = versionCode; } + + public List getStores() { return stores; } + public void setStores(List stores) { this.stores = stores; } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/StoreRemoteState.java b/update-service/src/main/java/com/xuqm/update/model/StoreRemoteState.java new file mode 100644 index 0000000..57ed4a4 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/StoreRemoteState.java @@ -0,0 +1,113 @@ +package com.xuqm.update.model; + +import com.xuqm.update.entity.AppStoreConfigEntity; + +/** + * Unified remote state returned by all vendor app store APIs. + * Normalises Huawei, Xiaomi, OPPO, vivo and Honor responses into one model. + */ +public class StoreRemoteState { + + public enum ReviewState { + ONLINE("已上线"), + UNDER_REVIEW("审核中"), + UNDER_REVIEW_XIAOMI("审核中(版本号可能不准确)"), + REJECTED("已拒绝"), + NOT_FOUND("未找到"), + UNKNOWN("未知状态"), + QUERY_FAILED("查询失败"); + + private final String label; + + ReviewState(String label) { this.label = label; } + + public String getLabel() { return label; } + } + + private AppStoreConfigEntity.StoreType storeType; + private ReviewState reviewState; + private String onlineVersionName; + private String onlineVersionCode; + private String reviewVersionName; + private String reviewVersionCode; + private boolean currentSubmissionLive; + private boolean nonCurrentRelease; + private boolean canSubmit; + private String blockReason; + private String rawResponse; + private String rawError; + + public static StoreRemoteState ok(AppStoreConfigEntity.StoreType storeType, + ReviewState reviewState, + String onlineVersionName, + String onlineVersionCode, + String reviewVersionName, + String reviewVersionCode, + boolean currentSubmissionLive, + boolean nonCurrentRelease, + boolean canSubmit, + String blockReason, + String rawResponse) { + StoreRemoteState s = new StoreRemoteState(); + s.storeType = storeType; + s.reviewState = reviewState; + s.onlineVersionName = onlineVersionName == null ? "" : onlineVersionName; + s.onlineVersionCode = onlineVersionCode == null ? "" : onlineVersionCode; + s.reviewVersionName = reviewVersionName == null ? "" : reviewVersionName; + s.reviewVersionCode = reviewVersionCode == null ? "" : reviewVersionCode; + s.currentSubmissionLive = currentSubmissionLive; + s.nonCurrentRelease = nonCurrentRelease; + s.canSubmit = canSubmit; + s.blockReason = blockReason == null ? "" : blockReason; + s.rawResponse = rawResponse == null ? "" : rawResponse; + return s; + } + + public static StoreRemoteState failed(AppStoreConfigEntity.StoreType storeType, + String rawError, + String blockReason) { + StoreRemoteState s = new StoreRemoteState(); + s.storeType = storeType; + s.reviewState = ReviewState.QUERY_FAILED; + s.rawError = rawError == null ? "" : rawError; + s.blockReason = blockReason == null ? "查询失败,不能确认状态" : blockReason; + s.canSubmit = false; + return s; + } + + public AppStoreConfigEntity.StoreType getStoreType() { return storeType; } + public void setStoreType(AppStoreConfigEntity.StoreType storeType) { this.storeType = storeType; } + + public ReviewState getReviewState() { return reviewState; } + public void setReviewState(ReviewState reviewState) { this.reviewState = reviewState; } + + public String getOnlineVersionName() { return onlineVersionName; } + public void setOnlineVersionName(String onlineVersionName) { this.onlineVersionName = onlineVersionName; } + + public String getOnlineVersionCode() { return onlineVersionCode; } + public void setOnlineVersionCode(String onlineVersionCode) { this.onlineVersionCode = onlineVersionCode; } + + public String getReviewVersionName() { return reviewVersionName; } + public void setReviewVersionName(String reviewVersionName) { this.reviewVersionName = reviewVersionName; } + + public String getReviewVersionCode() { return reviewVersionCode; } + public void setReviewVersionCode(String reviewVersionCode) { this.reviewVersionCode = reviewVersionCode; } + + public boolean isCurrentSubmissionLive() { return currentSubmissionLive; } + public void setCurrentSubmissionLive(boolean currentSubmissionLive) { this.currentSubmissionLive = currentSubmissionLive; } + + public boolean isNonCurrentRelease() { return nonCurrentRelease; } + public void setNonCurrentRelease(boolean nonCurrentRelease) { this.nonCurrentRelease = nonCurrentRelease; } + + public boolean isCanSubmit() { return canSubmit; } + public void setCanSubmit(boolean canSubmit) { this.canSubmit = canSubmit; } + + public String getBlockReason() { return blockReason; } + public void setBlockReason(String blockReason) { this.blockReason = blockReason; } + + public String getRawResponse() { return rawResponse; } + public void setRawResponse(String rawResponse) { this.rawResponse = rawResponse; } + + public String getRawError() { return rawError; } + public void setRawError(String rawError) { this.rawError = rawError; } +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index 0f2bb1e..8cb3a78 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -42,4 +42,11 @@ public interface AppVersionRepository extends JpaRepository findByAppKeyAndPlatformAndVersionCodeAndIdNot( String appKey, AppVersionEntity.Platform platform, int versionCode, String id); + + Optional findByAppKeyAndPlatformAndPackageNameAndVersionCode( + String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode); + + List findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus( + String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode, + AppVersionEntity.PublishStatus publishStatus); } 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 e4f0d4b..cfb4fa8 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 @@ -313,18 +313,7 @@ public class AppStoreService { v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { - v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); - log.info("Auto-published version {} after all stores approved", versionId); - operationLogService.record( - v.getAppKey(), - "APP_VERSION", - v.getId(), - "AUTO_PUBLISH", - reason, - Map.of( - "storeType", storeType, - "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name() - )); + applyPendingPublishPlan(v, storeType, reason); } AppVersionEntity saved = versionRepo.save(v); @@ -365,6 +354,14 @@ public class AppStoreService { String storeType, boolean preExisting, String reason) throws Exception { + return updateStoreReviewLive(versionId, storeType, preExisting, reason, null); + } + + public AppVersionEntity updateStoreReviewLive(String versionId, + String storeType, + boolean preExisting, + String reason, + Map extraPayload) throws Exception { synchronized (lockFor(versionId)) { AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); @@ -382,6 +379,9 @@ public class AppStoreService { if (preExisting) { extra.put("preExisting", true); } + if (extraPayload != null) { + extra.putAll(extraPayload); + } reviewMap.put(storeType, reviewPayload( AppVersionEntity.StoreReviewState.APPROVED.name(), reason == null ? "版本已在应用商店上线" : reason, @@ -393,10 +393,7 @@ public class AppStoreService { v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { - v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); - log.info("Auto-published version {} (live-on-store detection, store={})", versionId, storeType); - operationLogService.record(v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_PUBLISH", reason, - Map.of("storeType", storeType, "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name())); + applyPendingPublishPlan(v, storeType, reason); } AppVersionEntity saved = versionRepo.save(v); @@ -641,8 +638,57 @@ public class AppStoreService { private boolean allApproved(AppVersionEntity v, Map reviewMap) throws Exception { if (v.getStoreSubmitTargets() == null) return false; List targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {}); - return targets.stream().allMatch(t -> - AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(reviewMap.get(t)))); + return targets.stream().allMatch(t -> { + Object entry = reviewMap.get(t); + if (!AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(entry))) { + return false; + } + if (entry instanceof Map map) { + Object currentSubmissionLive = map.get("currentSubmissionLive"); + return !(currentSubmissionLive instanceof Boolean b) || b; + } + return true; + }); + } + + /** + * Apply the pending publish plan when all targeted stores are approved. + * If pending plan is IMMEDIATE -> publish now. + * If pending plan is SCHEDULED -> set scheduledPublishAt, keep DRAFT. + */ + private void applyPendingPublishPlan(AppVersionEntity v, String triggerStoreType, String reason) { + String pendingType = v.getPendingStorePublishType(); + if ("IMMEDIATE".equalsIgnoreCase(pendingType)) { + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + v.setPendingStorePublishType(null); + v.setPendingStorePublishScheduledAt(null); + log.info("Auto-published version {} after all stores approved (pending plan=IMMEDIATE)", v.getId()); + operationLogService.record( + v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_PUBLISH", + reason, Map.of("storeType", triggerStoreType, + "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name(), + "pendingPlan", "IMMEDIATE")); + } else if ("SCHEDULED".equalsIgnoreCase(pendingType) && v.getPendingStorePublishScheduledAt() != null) { + v.setScheduledPublishAt(v.getPendingStorePublishScheduledAt()); + v.setPendingStorePublishType(null); + v.setPendingStorePublishScheduledAt(null); + log.info("Auto-scheduled version {} after all stores approved (pending plan=SCHEDULED at {})", + v.getId(), v.getScheduledPublishAt()); + operationLogService.record( + v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_SCHEDULE", + reason, Map.of("storeType", triggerStoreType, + "publishStatus", AppVersionEntity.PublishStatus.DRAFT.name(), + "scheduledPublishAt", v.getScheduledPublishAt().toString(), + "pendingPlan", "SCHEDULED")); + } else { + // Default: immediate publish when no pending plan exists (backward compatible) + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + log.info("Auto-published version {} after all stores approved (no pending plan)", v.getId()); + operationLogService.record( + v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_PUBLISH", + reason, Map.of("storeType", triggerStoreType, + "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name())); + } } private String resolveWebhookSecret(String appKey) { 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 fb65858..f426453 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 @@ -5,6 +5,7 @@ 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 com.xuqm.update.repository.AppStoreConfigRepository; import com.xuqm.update.repository.AppVersionRepository; import org.slf4j.Logger; @@ -26,8 +27,14 @@ import java.io.File; import java.io.ByteArrayOutputStream; import java.io.FileInputStream; import java.io.InputStream; +import java.io.OutputStream; import java.math.BigInteger; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -62,6 +69,10 @@ public class StoreSubmissionService { private static final String HONOR_IAM = "https://iam.developer.honor.com"; private final RestTemplate rest = buildRestTemplate(); + private final HttpClient miHttp = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .version(HttpClient.Version.HTTP_1_1) + .build(); private final AppVersionRepository versionRepo; private final AppStoreConfigRepository configRepo; private final AppStoreService storeService; @@ -72,6 +83,19 @@ public class StoreSubmissionService { @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; + // vivo query rate-limit cache: key = appKey + ":" + packageName + private final java.util.concurrent.ConcurrentMap vivoQueryCache = new java.util.concurrent.ConcurrentHashMap<>(); + private static final long VIVO_CACHE_TTL_MS = 30_000; + + private static final class VivoCacheEntry { + final StoreRemoteState state; + final long timestamp; + VivoCacheEntry(StoreRemoteState state, long timestamp) { + this.state = state; + this.timestamp = timestamp; + } + } + public StoreSubmissionService(AppVersionRepository versionRepo, AppStoreConfigRepository configRepo, AppStoreService storeService, @@ -352,6 +376,276 @@ public class StoreSubmissionService { } } + public AppVersionEntity getVersionForPreflight(String versionId) { + return versionRepo.findById(versionId) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId)); + } + + /** + * Preflight check before submitting to app stores. + * Queries each target store's remote state and returns whether submission is allowed. + */ + public List preflightStoreSubmission(String versionId) { + AppVersionEntity v = versionRepo.findById(versionId).orElse(null); + if (v == null) { + throw new IllegalArgumentException("Version not found: " + versionId); + } + List targets = parseTargets(v.getStoreSubmitTargets()); + if (targets.isEmpty()) { + targets = storeService.resolveDefaultStoreTargets(v.getAppKey()); + } + 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 state = queryRemoteState(storeType, v, creds); + // Apply preflight rules + String submittedCode = String.valueOf(v.getVersionCode()); + String submittedName = v.getVersionName(); + if (state.isCurrentSubmissionLive() || + (submittedCode.equals(state.getOnlineVersionCode()) && !submittedCode.isBlank())) { + state.setCanSubmit(false); + state.setBlockReason("当前版本已在应用商店上线,禁止重复提审"); + } else if (state.getReviewState() == StoreRemoteState.ReviewState.UNDER_REVIEW && + submittedCode.equals(state.getReviewVersionCode()) && !submittedCode.isBlank()) { + state.setCanSubmit(false); + state.setBlockReason("当前版本正在审核中,禁止重复提审"); + } else if (state.getReviewState() == StoreRemoteState.ReviewState.QUERY_FAILED) { + state.setCanSubmit(false); + state.setBlockReason("查询失败,不能确认状态"); + } else if (!state.getOnlineVersionCode().isBlank() && !submittedCode.equals(state.getOnlineVersionCode())) { + state.setNonCurrentRelease(true); + state.setCanSubmit(true); + } else { + state.setCanSubmit(true); + } + results.add(state); + } catch (Exception e) { + log.warn("Preflight query failed for {}/{}: {}", v.getId(), storeType, e.getMessage()); + results.add(StoreRemoteState.failed( + AppStoreConfigEntity.StoreType.valueOf(storeType), + describeException(e), + "查询失败,不能确认状态")); + } + } + return results; + } + + /** + * Query remote state for a single store. Returns a unified StoreRemoteState. + */ + private StoreRemoteState queryRemoteState(String storeType, AppVersionEntity v, Map creds) throws Exception { + return switch (storeType) { + case "HUAWEI" -> queryHuaweiRemoteState(v, creds); + case "MI" -> queryMiRemoteState(v, creds); + case "OPPO" -> queryOppoRemoteState(v, creds); + case "VIVO" -> queryVivoRemoteState(v, creds); + case "HONOR" -> queryHonorRemoteState(v, creds); + default -> throw new IllegalArgumentException("Unknown store: " + storeType); + }; + } + + private StoreRemoteState queryHuaweiRemoteState(AppVersionEntity v, Map creds) throws Exception { + String clientId = require(creds, "clientId", "HUAWEI"); + String clientSecret = require(creds, "clientSecret", "HUAWEI"); + String pkg = requirePackageName(v); + String token = huaweiGetToken(clientId, clientSecret); + String hwAppId = huaweiGetAppId(clientId, token, pkg); + HttpHeaders headers = huaweiHeaders(clientId, token); + ResponseEntity resp = rest.exchange( + HUAWEI_API + "/api/publish/v2/app-info?appId=" + hwAppId, + HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = requireBodyMap(resp.getBody(), "HUAWEI app-info query"); + Map appInfoMap = body.get("appInfo") instanceof Map m + ? (Map) m : body; + String onShelfCode = String.valueOf(appInfoMap.getOrDefault("onShelfVersionCode", "")); + String onShelfName = firstNonBlank( + stringValue(appInfoMap.get("onShelfVersionName")), + stringValue(appInfoMap.get("versionName")), + stringValue(appInfoMap.get("appVersionName"))); + String versionCode = String.valueOf(appInfoMap.getOrDefault("versionCode", "")); + String versionName = stringValue(appInfoMap.get("versionName")); + String submittedCode = String.valueOf(v.getVersionCode()); + Object releaseStateObj = appInfoMap.get("releaseState"); + int releaseState = releaseStateObj != null ? Integer.parseInt(String.valueOf(releaseStateObj)) : -1; + boolean isLive = !submittedCode.isBlank() && submittedCode.equals(onShelfCode); + StoreRemoteState.ReviewState reviewState; + if (isLive) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else if (releaseState == 7) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (releaseState == 4 || releaseState == 5) { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } else if (releaseState == 0) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else { + reviewState = StoreRemoteState.ReviewState.UNKNOWN; + } + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.HUAWEI, + reviewState, + onShelfName, onShelfCode, + versionName, versionCode, + isLive, !isLive && !onShelfCode.isBlank(), + true, "", sanitizeJson(resp.getBody())); + } + + private StoreRemoteState queryMiRemoteState(AppVersionEntity v, Map creds) throws Exception { + String account = resolveMiAccount(creds); + String publicKey = resolveMiPublicKey(creds); + String privateKey = require(creds, "privateKey", "MI"); + JsonNode root = miGetAppInfo(account, requirePackageName(v), publicKey, privateKey); + JsonNode pkg = root.path("packageInfo"); + String onlineVersionCode = pkg.path("versionCode").asText(""); + String onlineVersionName = pkg.path("versionName").asText(""); + 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); + StoreRemoteState.ReviewState reviewState; + if (appStatus == 5) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (updateVersion) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; + } + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.MI, + reviewState, + onlineVersionName, onlineVersionCode, + "", "", + isLive, !isLive && !onlineVersionCode.isBlank(), + true, "", sanitizeJson(root)); + } + + private StoreRemoteState queryOppoRemoteState(AppVersionEntity v, Map creds) throws Exception { + String clientId = require(creds, "clientId", "OPPO"); + String clientSecret = require(creds, "clientSecret", "OPPO"); + String token = oppoGetToken(clientId, clientSecret); + JsonNode appData = oppoGetAppInfo(token, requirePackageName(v), clientId, clientSecret); + String onlineVersionCode = appData.path("versionCode").asText(""); + String onlineVersionName = appData.path("versionName").asText(""); + int auditInt = appData.path("audit_status").asInt(appData.path("auditStatus").asInt(-1)); + String submittedCode = String.valueOf(v.getVersionCode()); + boolean isLive = auditInt == 111 || List.of("2", "3", "4").contains(String.valueOf(auditInt)); + StoreRemoteState.ReviewState reviewState; + if (auditInt == 444 || "5".equals(String.valueOf(auditInt))) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (isLive) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.OPPO, + reviewState, + onlineVersionName, onlineVersionCode, + "", "", + isLive && submittedCode.equals(onlineVersionCode), + isLive && !submittedCode.equals(onlineVersionCode), + true, "", sanitizeJson(appData)); + } + + private StoreRemoteState queryVivoRemoteState(AppVersionEntity v, Map creds) throws Exception { + String accessKey = require(creds, "accessKey", "VIVO"); + String accessSecret = require(creds, "accessSecret", "VIVO"); + String pkg = requirePackageName(v); + String cacheKey = v.getAppKey() + ":" + pkg; + VivoCacheEntry cached = vivoQueryCache.get(cacheKey); + if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) { + log.debug("VIVO query cache hit for {}", cacheKey); + return cached.state; + } + String url = vivoRequestUrl(accessKey, accessSecret, "app.query.details", Map.of("packageName", pkg)); + ResponseEntity resp = rest.getForEntity(url, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody())); + JsonNode data = root.path("data"); + int status = data.path("status").asInt(-1); + String onlineVersionCode = data.path("versionCode").asText(""); + String onlineVersionName = data.path("versionName").asText(""); + String submittedCode = String.valueOf(v.getVersionCode()); + boolean isLive = status == 3; + StoreRemoteState.ReviewState reviewState; + if (status == 4) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (status == 3) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else if (status == 2) { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } else if (status == 1) { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } else { + reviewState = StoreRemoteState.ReviewState.UNKNOWN; + } + StoreRemoteState state = StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.VIVO, + reviewState, + onlineVersionName, onlineVersionCode, + "", "", + isLive && submittedCode.equals(onlineVersionCode), + isLive && !submittedCode.equals(onlineVersionCode), + true, "", sanitizeJson(root)); + vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis())); + return state; + } + + private StoreRemoteState queryHonorRemoteState(AppVersionEntity v, Map creds) throws Exception { + String clientId = require(creds, "clientId", "HONOR"); + String clientSecret = require(creds, "clientSecret", "HONOR"); + String token = honorGetToken(clientId, clientSecret); + int appId = honorGetAppId(token, requirePackageName(v)); + HttpHeaders headers = honorHeaders(token); + String url = HONOR_API + "/openapi/v1/publish/get-app-current-release?appId=" + appId; + ResponseEntity resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = requireBodyMap(resp.getBody(), "HONOR current-release query"); + assertHonorSuccess(body, "current-release query"); + Map data = body.get("data") instanceof Map d + ? (Map) d : body; + Object auditResult = data.get("auditResult"); + int status = auditResult != null ? Integer.parseInt(String.valueOf(auditResult)) : -1; + String onlineVersionCode = String.valueOf(data.getOrDefault("versionCode", "")); + String onlineVersionName = stringValue(data.get("versionName")); + String submittedCode = String.valueOf(v.getVersionCode()); + boolean isLive = status == 1; + StoreRemoteState.ReviewState reviewState; + if (status == 2) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else if (status == 1) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else if (status == 0) { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } else { + reviewState = StoreRemoteState.ReviewState.UNKNOWN; + } + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.HONOR, + reviewState, + onlineVersionName, onlineVersionCode, + "", "", + isLive && submittedCode.equals(onlineVersionCode), + isLive && !submittedCode.equals(onlineVersionCode), + true, "", sanitizeJson(body)); + } + /** * Poll vendor APIs every 10 minutes for versions with UNDER_REVIEW or REJECTED stores. * @@ -390,30 +684,28 @@ public class StoreSubmissionService { if (cfg == null || !cfg.isEnabled()) continue; try { Map creds = parseConfig(cfg.getConfigJson()); - AppVersionEntity.StoreReviewState polled = pollStoreSingleReviewState(storeType, v, creds); + StoreRemoteState polled = queryRemoteState(storeType, v, creds); if (polled == null) { log.debug("Store review poll: {}/{} returned null (no poll API for this store)", v.getId(), storeType); } else if (isUnderReview) { - if (polled != AppVersionEntity.StoreReviewState.UNDER_REVIEW) { - log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, polled); - if (polled == AppVersionEntity.StoreReviewState.APPROVED) { - storeService.updateStoreReviewLive(v.getId(), storeType, false, "厂商审核状态轮询检测:版本已上线"); + AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState()); + if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) { + log.info("Store review poll: {}/{} status changed 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, polled, "厂商审核状态轮询检测"); + storeService.updateStoreReview(v.getId(), storeType, mappedState, "厂商审核状态轮询检测"); } } else { log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType); } } else { - // REJECTED store — check if the specific submitted versionCode is now live. - // Only HUAWEI has a reliable version-specific check (compares onShelfVersionCode). - // Other store polls return the live status for ANY version, so we can't trust - // their APPROVED result here without risking false positives (e.g. an older - // version is live but we'd incorrectly mark the new submission as pre-existing). - if (polled == AppVersionEntity.StoreReviewState.APPROVED && "HUAWEI".equals(storeType)) { - log.info("Store review poll: {}/{} was REJECTED but Huawei confirms this versionCode is on-shelf — marking pre-existing", v.getId(), storeType); - storeService.updateStoreReviewLive(v.getId(), storeType, true, - "版本已在应用商店上线(提交失败但检测到相同版本已上线,可能为直接上传)"); + 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(), + buildLiveReason(polled), buildExtra(polled)); } // otherwise leave REJECTED as-is } @@ -424,144 +716,41 @@ public class StoreSubmissionService { } } - @SuppressWarnings("unchecked") - private AppVersionEntity.StoreReviewState pollStoreSingleReviewState(String storeType, - AppVersionEntity v, - Map creds) throws Exception { - return switch (storeType) { - case "VIVO" -> { - String accessKey = require(creds, "accessKey", "VIVO"); - String accessSecret = require(creds, "accessSecret", "VIVO"); - String pkg = requirePackageName(v); - String url = vivoRequestUrl(accessKey, accessSecret, "app.query.details", Map.of("packageName", pkg)); - ResponseEntity resp = rest.getForEntity(url, String.class); - com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody())); - // VIVO data.status: 1=待审核, 2=审核中, 3=已上线(审核通过), 4=审核驳回, 5=已下架 - int status = root.path("data").path("status").asInt(-1); - log.info("VIVO poll status={} data={} for version={}", status, root.path("data"), v.getId()); - yield switch (status) { - case 3 -> AppVersionEntity.StoreReviewState.APPROVED; - case 4 -> AppVersionEntity.StoreReviewState.REJECTED; - default -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; - }; - } - case "OPPO" -> { - String clientId = require(creds, "clientId", "OPPO"); - String clientSecret = require(creds, "clientSecret", "OPPO"); - String token = oppoGetToken(clientId, clientSecret); - com.fasterxml.jackson.databind.JsonNode appData = oppoGetAppInfo(token, requirePackageName(v), clientId, clientSecret); - // OPPO audit_status (integer): 111=已上线(审核通过), 444=被拒绝, other=审核中 - // Legacy string fallback also kept: "2"/"3"/"4"=通过, "5"=驳回 - log.info("OPPO poll appData for version={}: {}", v.getId(), appData); - int auditInt = appData.path("audit_status").asInt(appData.path("auditStatus").asInt(-1)); - String auditStr = String.valueOf(auditInt); - log.info("OPPO poll audit_status={} audit_status_name={} for version={}", - auditInt, appData.path("audit_status_name").asText(""), v.getId()); - yield switch (auditInt) { - case 111 -> AppVersionEntity.StoreReviewState.APPROVED; - case 444 -> AppVersionEntity.StoreReviewState.REJECTED; - default -> switch (auditStr) { - case "2", "3", "4" -> AppVersionEntity.StoreReviewState.APPROVED; - case "5" -> AppVersionEntity.StoreReviewState.REJECTED; - default -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; - }; - }; - } - case "HUAWEI" -> { - String clientId = require(creds, "clientId", "HUAWEI"); - String clientSecret = require(creds, "clientSecret", "HUAWEI"); - String pkg = requirePackageName(v); - String token = huaweiGetToken(clientId, clientSecret); - String hwAppId = huaweiGetAppId(clientId, token, pkg); - org.springframework.http.HttpHeaders headers = huaweiHeaders(clientId, token); - ResponseEntity resp = rest.exchange( - HUAWEI_API + "/api/publish/v2/app-info?appId=" + hwAppId, - org.springframework.http.HttpMethod.GET, new HttpEntity<>(headers), java.util.Map.class); - Map body = requireBodyMap(resp.getBody(), "HUAWEI app-info poll"); - Map appInfoMap = body.get("appInfo") instanceof Map m - ? (Map) m : body; - // Compare onShelfVersionCode to what we submitted: if they match, the version is published. - String onShelfCode = String.valueOf(appInfoMap.getOrDefault("onShelfVersionCode", "")); - String submittedCode = String.valueOf(v.getVersionCode()); - log.info("HUAWEI poll onShelfVersionCode={} submitted={} for version={}", onShelfCode, submittedCode, v.getId()); - if (!submittedCode.isBlank() && submittedCode.equals(onShelfCode)) { - yield AppVersionEntity.StoreReviewState.APPROVED; - } - // Also check releaseState for explicit rejection: 7=审核拒绝 - Object releaseStateObj = appInfoMap.get("releaseState"); - int releaseState = releaseStateObj != null ? Integer.parseInt(String.valueOf(releaseStateObj)) : -1; - log.info("HUAWEI poll releaseState={} for version={}", releaseState, v.getId()); - yield releaseState == 7 ? AppVersionEntity.StoreReviewState.REJECTED - : AppVersionEntity.StoreReviewState.UNDER_REVIEW; - } - case "HONOR" -> { - String clientId = require(creds, "clientId", "HONOR"); - String clientSecret = require(creds, "clientSecret", "HONOR"); - String token = honorGetToken(clientId, clientSecret); - int appId = honorGetAppId(token, requirePackageName(v)); - org.springframework.http.HttpHeaders headers = honorHeaders(token); - // Correct endpoint confirmed via XiaoZhuan reference: get-app-current-release - String url = HONOR_API + "/openapi/v1/publish/get-app-current-release?appId=" + appId; - ResponseEntity resp = rest.exchange(url, org.springframework.http.HttpMethod.GET, - new HttpEntity<>(headers), java.util.Map.class); - @SuppressWarnings("unchecked") - Map body = requireBodyMap(resp.getBody(), "HONOR current-release poll"); - log.info("HONOR poll raw response for version={}: {}", v.getId(), body); - assertHonorSuccess(body, "current-release poll"); - // HONOR auditResult: 0=审核中, 1=审核通过(已上线), 2=审核不通过, 3/4=未提交或其他 - @SuppressWarnings("unchecked") - Map data = body.get("data") instanceof Map d - ? (Map) d : body; - Object auditResult = data.get("auditResult"); - int status = auditResult != null ? Integer.parseInt(String.valueOf(auditResult)) : -1; - log.info("HONOR poll auditResult={} for version={}", status, v.getId()); - yield switch (status) { - case 1 -> AppVersionEntity.StoreReviewState.APPROVED; - case 2 -> AppVersionEntity.StoreReviewState.REJECTED; - default -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; - }; - } - case "MI" -> { - String account = resolveMiAccount(creds); - String publicKey = resolveMiPublicKey(creds); - String privateKey = require(creds, "privateKey", "MI"); - JsonNode root = miGetAppInfo(account, requirePackageName(v), publicKey, privateKey); - // Log full root response so we can verify exact field names from production - log.info("MI poll full response for version={}: {}", v.getId(), root); - JsonNode pkg = root.path("packageInfo"); - // Xiaomi /devupload/dev/query returns status of the currently-published app, - // NOT the pending submission. appStatus=3 means the old version is online — - // it does NOT mean the newly-submitted version was accepted. - // - // Reliable fields: - // root.updateVersion (bool): true = new version update available to users - // (i.e. the newly submitted version is APPROVED/published) - // false = users cannot update (new version still under review OR rejected) - // packageInfo.appStatus: 5 = this version was explicitly rejected/驳回 - // packageInfo.synchroResult: 1=上线成功, 0=拒绝/失败 - int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1)); - // updateVersion=false means the submitted version is not yet live (either under review or rejected) - // We need a DIFFERENT signal to distinguish rejected from still-under-review - boolean updateVersion = root.path("updateVersion").asBoolean(false); - log.info("MI poll appStatus={} updateVersion={} for version={}", appStatus, updateVersion, v.getId()); - // appStatus=5 is an explicit rejection signal - if (appStatus == 5) { - yield AppVersionEntity.StoreReviewState.REJECTED; - } - // updateVersion=true means the submitted version is now live/approved - if (updateVersion) { - yield AppVersionEntity.StoreReviewState.APPROVED; - } - // Fall back: synchroResult distinguishes rejected(0) from still-pending(-1) - int synchroResult = pkg.path("synchroResult").asInt(-1); - log.info("MI poll synchroResult={} for version={}", synchroResult, v.getId()); - yield synchroResult == 0 ? AppVersionEntity.StoreReviewState.REJECTED - : AppVersionEntity.StoreReviewState.UNDER_REVIEW; - } - default -> null; // APP_STORE, GOOGLE_PLAY: no vendor query API + private AppVersionEntity.StoreReviewState mapToStoreReviewState(StoreRemoteState.ReviewState reviewState) { + return switch (reviewState) { + case ONLINE -> AppVersionEntity.StoreReviewState.APPROVED; + case UNDER_REVIEW, UNDER_REVIEW_XIAOMI -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; + case REJECTED -> AppVersionEntity.StoreReviewState.REJECTED; + default -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; }; } + private String buildLiveReason(StoreRemoteState result) { + if (result.isCurrentSubmissionLive()) { + return "厂商审核状态轮询检测:本次提交版本已上线"; + } + String version = firstNonBlank(result.getOnlineVersionName(), result.getOnlineVersionCode()); + if (!version.isBlank()) { + return "应用商店已有线上版本 " + version + ",非本次发布"; + } + return "应用商店已有线上版本,非本次发布"; + } + + private Map buildExtra(StoreRemoteState result) { + Map extra = new LinkedHashMap<>(); + extra.put("currentSubmissionLive", result.isCurrentSubmissionLive()); + if (!result.getOnlineVersionName().isBlank()) { + extra.put("liveVersionName", result.getOnlineVersionName()); + } + if (!result.getOnlineVersionCode().isBlank()) { + extra.put("liveVersionCode", result.getOnlineVersionCode()); + } + if (result.isNonCurrentRelease()) { + extra.put("nonCurrentRelease", true); + } + return extra; + } + /** * On startup, mark any stores stuck in SUBMITTING as FAILED. * A SUBMITTING state with no corresponding active thread means the service was @@ -1321,17 +1510,56 @@ public class StoreSubmissionService { * Change when the approved version goes live. * publishType: "IMMEDIATE" or "SCHEDULED" * scheduledAt: required when publishType == "SCHEDULED" + * + * Three-state logic: + * 1. PUBLISHED -> reject (already live, no schedule change needed) + * 2. DRAFT + all stores APPROVED -> modify scheduledPublishAt directly + * 3. UNDER_REVIEW -> save to pending fields, apply after all stores approve */ public AppVersionEntity updatePublishSchedule(String versionId, String publishType, java.time.LocalDateTime scheduledAt) { AppVersionEntity v = versionRepo.findById(versionId) .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId)); + + // 1. Already published -> reject + if (v.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) { + throw new IllegalArgumentException("版本已上线,无需修改发布计划"); + } + + // Check if all targeted stores are approved + boolean allApproved = isAllStoresApproved(v); + boolean anyUnderReview = isAnyStoreUnderReview(v); + if ("IMMEDIATE".equalsIgnoreCase(publishType)) { - v.setScheduledPublishAt(null); + if (anyUnderReview) { + // 3. Under review -> save as pending plan + v.setPendingStorePublishType("IMMEDIATE"); + v.setPendingStorePublishScheduledAt(null); + v.setPendingStorePublishUpdatedAt(LocalDateTime.now()); + } else if (allApproved) { + // 2. All approved but not published -> publish immediately + v.setScheduledPublishAt(null); + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + } else { + // No active review -> just clear schedule + v.setScheduledPublishAt(null); + } } else if ("SCHEDULED".equalsIgnoreCase(publishType)) { if (scheduledAt == null) throw new IllegalArgumentException("scheduledAt required for SCHEDULED publish"); - v.setScheduledPublishAt(scheduledAt); + if (anyUnderReview) { + // 3. Under review -> save as pending plan + v.setPendingStorePublishType("SCHEDULED"); + v.setPendingStorePublishScheduledAt(scheduledAt); + v.setPendingStorePublishUpdatedAt(LocalDateTime.now()); + } else if (allApproved) { + // 2. All approved but not published -> set schedule directly + v.setScheduledPublishAt(scheduledAt); + } else { + // No active review -> just set schedule + v.setScheduledPublishAt(scheduledAt); + } } + // Try to call HUAWEI API to update release plan if token available try { AppStoreConfigEntity cfg = configRepo @@ -1347,6 +1575,45 @@ public class StoreSubmissionService { return versionRepo.save(v); } + private boolean isAllStoresApproved(AppVersionEntity v) { + if (v.getStoreSubmitTargets() == null || v.getStoreReviewStatus() == null) return false; + try { + List targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference>() {}); + @SuppressWarnings("unchecked") + Map> reviewMap = + mapper.readValue(v.getStoreReviewStatus(), new TypeReference>>() {}); + for (String t : targets) { + Map info = reviewMap.get(t); + if (info == null) return false; + String state = info.get("state") instanceof String s ? s : ""; + if (!"APPROVED".equals(state)) return false; + Object currentSubmissionLive = info.get("currentSubmissionLive"); + if (currentSubmissionLive instanceof Boolean b && !b) return false; + } + return !targets.isEmpty(); + } catch (Exception e) { + return false; + } + } + + private boolean isAnyStoreUnderReview(AppVersionEntity v) { + if (v.getStoreReviewStatus() == null) return false; + try { + @SuppressWarnings("unchecked") + Map> reviewMap = + mapper.readValue(v.getStoreReviewStatus(), new TypeReference>>() {}); + for (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; + } + private void huaweiUpdateReleasePlan(AppVersionEntity v, Map creds, String publishType, java.time.LocalDateTime scheduledAt) throws Exception { String clientId = require(creds, "clientId", "HUAWEI"); @@ -1423,32 +1690,69 @@ public class StoreSubmissionService { Map.of("name", "apk", "hash", md5Hex(apkFile)) )); - // Use curl ProcessBuilder: MI API server drops large multipart body with RestTemplate. - // curl sends Expect:100-continue which bypasses the server-side body timeout. - // --http1.1: MI API has HTTP/2 stream framing issues (curl exit 92); force HTTP/1.1. + // MI API drops large multipart bodies with RestTemplate in some environments. + // Build a multipart temp body and send it with Java HttpClient so containers do + // not depend on a system curl binary. Force HTTP/1.1 to avoid MI HTTP/2 issues. String requestDataJson = asJsonString(requestData); String sigJson = rsaEncryptHexChunked(asJsonString(sig), publicKey); - ProcessBuilder pb = new ProcessBuilder( - "curl", "-s", "--http1.1", "--connect-timeout", "30", - "--max-time", String.valueOf(130 * 60), - "-F", "apk=@" + apkFile.getAbsolutePath(), - "-F", "RequestData=" + requestDataJson, - "-F", "SIG=" + sigJson, - "https://api.developer.xiaomi.com/devupload/dev/push" - ); - pb.redirectErrorStream(true); - Process process = pb.start(); - String responseBody = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); - boolean completed = process.waitFor(130, java.util.concurrent.TimeUnit.MINUTES); - if (!completed) { - process.destroyForcibly(); - throw new IllegalStateException("curl upload to MI timed out"); + String boundary = "----XuqmMiUpload" + UUID.randomUUID(); + Path multipartFile = Files.createTempFile("xuqm-mi-upload-", ".multipart"); + try { + writeMiMultipartBody(multipartFile, boundary, apkFile, requestDataJson, sigJson); + HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.developer.xiaomi.com/devupload/dev/push")) + .timeout(Duration.ofMinutes(130)) + .version(HttpClient.Version.HTTP_1_1) + .expectContinue(true) + .header("Content-Type", "multipart/form-data; boundary=" + boundary) + .POST(HttpRequest.BodyPublishers.ofFile(multipartFile)) + .build(); + HttpResponse response = miHttp.send(request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String responseBody = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("MI upload HTTP " + response.statusCode() + ": " + responseBody); + } + if (responseBody.isEmpty()) { + throw new IllegalStateException("MI upload returned empty response"); + } + JsonNode root = mapper.readTree(responseBody); + miCheckSuccess(root, "上传Apk"); + } finally { + try { + Files.deleteIfExists(multipartFile); + } catch (Exception e) { + log.warn("Failed to delete MI multipart temp file {}: {}", multipartFile, e.getMessage()); + } } - if (responseBody.isEmpty()) { - throw new IllegalStateException("curl upload to MI returned empty response (exit=" + process.exitValue() + ")"); + } + + private void writeMiMultipartBody(Path target, + String boundary, + File apkFile, + String requestDataJson, + String sigJson) throws Exception { + try (OutputStream out = Files.newOutputStream(target)) { + out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"apk\"; filename=\"" + apkFile.getName() + "\"\r\n") + .getBytes(StandardCharsets.UTF_8)); + out.write("Content-Type: application/vnd.android.package-archive\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + Files.copy(apkFile.toPath(), out); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); + writeMultipartText(out, boundary, "RequestData", requestDataJson); + writeMultipartText(out, boundary, "SIG", sigJson); + out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8)); } - JsonNode root = mapper.readTree(responseBody); - miCheckSuccess(root, "上传Apk"); + } + + private void writeMultipartText(OutputStream out, + String boundary, + String name, + String value) throws Exception { + out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + out.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + out.write("\r\n".getBytes(StandardCharsets.UTF_8)); } private String resolveMiAccount(Map creds) { @@ -1979,6 +2283,22 @@ public class StoreSubmissionService { return ""; } + private String stringValue(Object value) { + if (value == null) return ""; + String text = String.valueOf(value).trim(); + return "null".equalsIgnoreCase(text) ? "" : text; + } + + private String firstNonBlank(String... values) { + if (values == null) return ""; + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + private String requiredText(Map map, String key, String action) { if (map == null) { throw new IllegalStateException(action + " failed: empty response body"); @@ -2064,6 +2384,40 @@ public class StoreSubmissionService { payload); } + /** + * Sanitise a raw API response before logging / storing it. + * Strips tokens, secrets, and other sensitive fields. + */ + private String sanitizeJson(Object raw) { + if (raw == null) return ""; + try { + String text = raw instanceof String s ? s : mapper.writeValueAsString(raw); + if (text.isBlank()) return ""; + // Simple string replacement to mask sensitive values + String[] sensitiveKeys = { + "access_token", "accessToken", "token", "client_secret", "clientSecret", + "secret", "password", "privateKey", "api_sign", "sig", "sign" + }; + String result = text; + for (String key : sensitiveKeys) { + // Match "key":"value" or key=value patterns + result = result.replaceAll( + "(?i)(\\\"" + key + "\\\":\\\")[^\\\"]{4,}\\\"", + "\\\"" + key + "\\\":\"***\""); + result = result.replaceAll( + "(?i)(" + key + "=)[^&\\s]{4,}", + key + "=***"); + } + // Truncate very large responses + if (result.length() > 8000) { + result = result.substring(0, 8000) + "... [truncated]"; + } + return result; + } catch (Exception e) { + return ""; + } + } + private String describeException(Throwable throwable) { if (throwable == null) { return "unknown error";