From 0ed09a82294f4e78735ed1ef8368b0fa686096d0 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 15 May 2026 22:11:03 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E5=A4=A7=E6=B3=A2=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../update/controller/AppStoreController.java | 48 +++- .../xuqm/update/service/AppStoreService.java | 15 +- .../service/StoreSubmissionService.java | 261 ++++++++++++++++-- 3 files changed, 297 insertions(+), 27 deletions(-) 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 34d6a96..396785c 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 @@ -114,18 +114,19 @@ public class AppStoreController { List storeTypes = body != null ? extractStringList(body, "storeTypes") : null; String submitMode = body != null ? (String) body.get("submitMode") : null; - String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null; + String scheduledPublishAtText = body != null ? (String) body.get("scheduledPublishAt") : null; Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null ? Boolean.valueOf(body.get("autoPublishAfterReview").toString()) : null; - java.time.LocalDateTime scheduledAt = null; - if (scheduledAtText != null && !scheduledAtText.isBlank()) { - scheduledAt = java.time.LocalDateTime.parse(scheduledAtText); + java.time.LocalDateTime scheduledPublishAt = null; + if (scheduledPublishAtText != null && !scheduledPublishAtText.isBlank()) { + scheduledPublishAt = java.time.LocalDateTime.parse(scheduledPublishAtText); } AppVersionEntity v = storeService.markSubmitted(versionId, storeTypes, submitMode, - scheduledAt, - autoPublishAfterReview); + null, + autoPublishAfterReview, + scheduledPublishAt); String normalizedMode = submitMode == null ? "MANUAL" : submitMode.trim().toUpperCase(); if (!"SCHEDULED".equals(normalizedMode)) { submissionService.executeSubmitAsync(versionId); @@ -161,6 +162,41 @@ public class AppStoreController { storeService.updateStoreReview(versionId, storeType, state, reason))); } + // ── Cancel review (撤回审核) ───────────────────────────────────────────── + + /** + * Withdraw app from store review. + * Body: { "storeTypes": ["HUAWEI", "MI"] } — optional, defaults to all active-review stores. + */ + @PostMapping("/app/{versionId}/cancel-review") + public ResponseEntity> cancelReview( + @PathVariable String versionId, + @RequestBody(required = false) Map body) throws Exception { + + List storeTypes = body != null ? extractStringList(body, "storeTypes") : null; + submissionService.cancelStoreReview(versionId, storeTypes); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + // ── Modify publish schedule ────────────────────────────────────────────── + + /** + * Change when an approved version goes live. + * Body: { "publishType": "IMMEDIATE" | "SCHEDULED", "scheduledAt": "2026-05-20T10:00:00" } + */ + @PutMapping("/app/{versionId}/publish-schedule") + public ResponseEntity> updatePublishSchedule( + @PathVariable String versionId, + @RequestBody Map body) throws Exception { + + String publishType = body.get("publishType") instanceof String s ? s : "IMMEDIATE"; + String scheduledAtText = body.get("scheduledAt") instanceof String s ? s : null; + java.time.LocalDateTime scheduledAt = scheduledAtText != null && !scheduledAtText.isBlank() + ? java.time.LocalDateTime.parse(scheduledAtText) : null; + return ResponseEntity.ok(ApiResponse.success( + submissionService.updatePublishSchedule(versionId, publishType, scheduledAt))); + } + private List extractStringList(Map body, String key) { Object value = body.get(key); if (value instanceof List list) { 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 8b5da29..8fd3b3c 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 @@ -115,6 +115,15 @@ public class AppStoreService { String submitMode, LocalDateTime scheduledAt, Boolean autoPublishAfterReview) throws Exception { + return markSubmitted(versionId, storeTypes, submitMode, scheduledAt, autoPublishAfterReview, null); + } + + public AppVersionEntity markSubmitted(String versionId, + List storeTypes, + String submitMode, + LocalDateTime scheduledAt, + Boolean autoPublishAfterReview, + LocalDateTime scheduledPublishAt) throws Exception { synchronized (lockFor(versionId)) { AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); List resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes); @@ -130,7 +139,8 @@ public class AppStoreService { // Withdraw lower versions' active reviews for the same stores cancelSupersededVersionReviews(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets); - Map reviewMap = new LinkedHashMap<>(); + // Merge with existing store statuses instead of replacing — preserves other stores' states + Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); for (String store : resolvedTargets) { reviewMap.put(store, reviewPayload( AppVersionEntity.StoreReviewState.PENDING.name(), @@ -144,6 +154,9 @@ public class AppStoreService { v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); v.setStoreSubmitMode(normalizedMode); v.setStoreSubmitScheduledAt(scheduledAt); + if (scheduledPublishAt != null) { + v.setScheduledPublishAt(scheduledPublishAt); + } if (autoPublishAfterReview != null) { v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode)); } 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 c8da571..32d1a38 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 @@ -324,12 +324,23 @@ public class StoreSubmissionService { // 5. Bind APK String pkgId = huaweiBindApk(clientId, token, hwAppId, file.getName(), objectId); - // 6. Wait for compile (poll up to 3 minutes) - huaweiWaitCompile(clientId, token, hwAppId, pkgId); + // 6. Brief compile check (up to 5 min) — HUAWEI queues compile+review internally so + // we submit regardless of compile status after the grace period. + huaweiWaitCompileOrContinue(clientId, token, hwAppId, pkgId); // 7. Update version description huaweiUpdateDesc(clientId, token, hwAppId, v.getChangeLog()); + // 7.5. Set planned release date if specified + if (v.getScheduledPublishAt() != null) { + try { + huaweiUpdateReleasePlan(v, creds, "SCHEDULED", v.getScheduledPublishAt()); + log.info("Huawei release plan set to SCHEDULED at {}", v.getScheduledPublishAt()); + } catch (Exception e) { + log.warn("Huawei release plan update failed (non-fatal): {}", e.getMessage()); + } + } + // 8. Submit for review huaweiSubmit(clientId, token, hwAppId); } @@ -436,28 +447,50 @@ public class StoreSubmissionService { return pkgId; } + /** + * Polls HUAWEI compile status for up to 5 minutes. + * If compile finishes (status=2) we return early; status=3 (failed) throws. + * If the deadline passes without a clear result we log and continue anyway — + * HUAWEI queues compile and review internally, so submitting before compile + * finishes is safe for large APKs that take longer than our grace period. + */ @SuppressWarnings("unchecked") - private void huaweiWaitCompile(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException { + private void huaweiWaitCompileOrContinue(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException { HttpHeaders headers = huaweiHeaders(clientId, token); - long deadline = System.currentTimeMillis() + 3 * 60_000L; + long deadline = System.currentTimeMillis() + 5 * 60_000L; while (System.currentTimeMillis() < deadline) { - Thread.sleep(10_000); - ResponseEntity resp = rest.exchange( - HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId, - HttpMethod.GET, new HttpEntity<>(headers), Map.class); - Map respBody = requireBodyMap(resp.getBody(), "Huawei compile status"); - List> states = asMapList(respBody.get("pkgStateList")); - if (states.isEmpty()) { - states = asMapList(respBody.get("data")); - } - if (states != null && !states.isEmpty()) { - Object compileStatus = states.get(0).get("compileStatus"); - if ("2".equals(String.valueOf(compileStatus))) return; // 2 = success - if ("3".equals(String.valueOf(compileStatus))) - throw new RuntimeException("Huawei compile failed: " + summarizeMap(states.get(0))); + Thread.sleep(15_000); + try { + ResponseEntity resp = rest.exchange( + HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId, + HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map respBody = requireBodyMap(resp.getBody(), "Huawei compile status"); + log.info("Huawei compile status raw: {}", summarizeMap(respBody)); + List> states = asMapList(respBody.get("pkgStateList")); + if (states.isEmpty()) states = asMapList(respBody.get("data")); + if (!states.isEmpty()) { + Map s0 = states.get(0); + // HUAWEI compile status API uses successStatus (0=pending,1=success,2=failed) + // or legacy compileStatus (2=done,3=failed). Try both. + Object compileStatus = s0.get("compileStatus"); + Object successStatus = s0.get("successStatus"); + log.info("Huawei compile state: compileStatus={} successStatus={} aabCompileStatus={}", + compileStatus, successStatus, s0.get("aabCompileStatus")); + if ("2".equals(String.valueOf(compileStatus))) return; // legacy done + if ("3".equals(String.valueOf(compileStatus))) + throw new RuntimeException("Huawei compile failed: " + summarizeMap(s0)); + if ("1".equals(String.valueOf(successStatus))) return; // new API done + if ("2".equals(String.valueOf(successStatus))) + throw new RuntimeException("Huawei compile failed (successStatus=2): " + summarizeMap(s0)); + } else { + log.info("Huawei compile pkgStateList empty, keys={}", respBody.keySet()); + } + } catch (RuntimeException e) { + if (e.getMessage() != null && e.getMessage().startsWith("Huawei compile failed")) throw e; + log.warn("Huawei compile status poll error (continuing): {}", e.getMessage()); } } - throw new RuntimeException("Huawei compile timeout"); + log.warn("Huawei compile grace period elapsed for pkgId={} — submitting anyway", pkgId); } private void huaweiUpdateDesc(String clientId, String token, String hwAppId, String changeLog) { @@ -469,10 +502,23 @@ public class StoreSubmissionService { HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class); } + @SuppressWarnings("unchecked") private void huaweiSubmit(String clientId, String token, String hwAppId) { HttpHeaders headers = huaweiHeaders(clientId, token); - rest.postForEntity(HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId, + ResponseEntity resp = rest.postForEntity( + HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId, new HttpEntity<>(headers), Map.class); + Map body = resp.getBody(); + log.info("Huawei app-submit response: {}", body != null ? summarizeMap(body) : "null"); + if (body != null) { + Object ret = body.get("ret"); + if (ret instanceof Map retMap) { + Object code = retMap.get("code"); + if (code != null && !"0".equals(String.valueOf(code))) { + throw new RuntimeException("Huawei submit failed: " + retMap.get("msg") + " (code=" + code + ")"); + } + } + } } private HttpHeaders huaweiHeaders(String clientId, String token) { @@ -698,6 +744,181 @@ public class StoreSubmissionService { log.info("Google Play submission is handled by the release script or Play Console; server-side submission service only records the market link and review tracking"); } + // ── Cancel review (撤回审核) ────────────────────────────────────────────── + + /** + * Cancel pending store review for the specified stores. + * Calls each store's withdrawal API (best-effort) then marks DB state as WITHDRAWN. + */ + public void cancelStoreReview(String versionId, List storeTypes) throws Exception { + AppVersionEntity v = versionRepo.findById(versionId) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId)); + + List targets = (storeTypes != null && !storeTypes.isEmpty()) + ? storeTypes + : extractActiveReviewTargets(v); + + for (String storeType : targets) { + try { + AppStoreConfigEntity cfg = configRepo + .findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType)) + .orElse(null); + if (cfg != null && cfg.isEnabled()) { + try { + Map creds = parseConfig(cfg.getConfigJson()); + cancelAtStore(storeType, v, creds); + } catch (Exception e) { + log.warn("Cancel API call failed for {}/{}: {}", versionId, storeType, e.getMessage()); + } + } + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.WITHDRAWN, "用户手动撤回"); + } catch (Exception e) { + log.error("Cancel review failed for {}/{}: {}", versionId, storeType, e.getMessage(), e); + } + } + } + + private List extractActiveReviewTargets(AppVersionEntity v) { + if (v.getStoreReviewStatus() == null || v.getStoreReviewStatus().isBlank()) return List.of(); + try { + @SuppressWarnings("unchecked") + Map> reviewMap = + mapper.readValue(v.getStoreReviewStatus(), new TypeReference<>() {}); + return reviewMap.entrySet().stream() + .filter(e -> { + Object state = e.getValue().get("state"); + String s = state == null ? "" : state.toString(); + return "PENDING".equals(s) || "SUBMITTING".equals(s) || "UNDER_REVIEW".equals(s); + }) + .map(Map.Entry::getKey) + .toList(); + } catch (Exception e) { + return List.of(); + } + } + + private void cancelAtStore(String storeType, AppVersionEntity v, Map creds) throws Exception { + switch (storeType) { + case "HUAWEI" -> cancelAtHuawei(v, creds); + case "HONOR" -> cancelAtHonor(v, creds); + case "OPPO" -> cancelAtOppo(v, creds); + case "VIVO" -> cancelAtVivo(v, creds); + // MI: no public cancel API + default -> log.info("No cancel API for store {}, DB-only withdrawal", storeType); + } + } + + private void cancelAtHuawei(AppVersionEntity v, Map creds) { + String clientId = require(creds, "clientId", "HUAWEI"); + String clientSecret = require(creds, "clientSecret", "HUAWEI"); + String packageName = requirePackageName(v); + String token = huaweiGetToken(clientId, clientSecret); + String hwAppId = huaweiGetAppId(clientId, token, packageName); + HttpHeaders headers = huaweiHeaders(clientId, token); + rest.postForEntity(HUAWEI_API + "/api/publish/v2/developer-service/unSubmit?appId=" + hwAppId, + new HttpEntity<>(headers), Map.class); + log.info("HUAWEI unSubmit called for appId={}", hwAppId); + } + + private void cancelAtHonor(AppVersionEntity v, Map creds) { + String clientId = require(creds, "clientId", "HONOR"); + String clientSecret = require(creds, "clientSecret", "HONOR"); + String packageName = requirePackageName(v); + String token = honorGetToken(clientId, clientSecret); + int appId = honorGetAppId(token, packageName); + HttpHeaders headers = honorHeaders(token); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity resp = rest.postForEntity( + HONOR_API + "/openapi/v1/publish/cancel-audit?appId=" + appId, + new HttpEntity<>(headers), Map.class); + assertHonorSuccess(resp.getBody(), "cancel-audit"); + log.info("HONOR cancel-audit called for appId={}", appId); + } + + private void cancelAtOppo(AppVersionEntity v, Map creds) throws Exception { + String clientId = require(creds, "clientId", "OPPO"); + String clientSecret = require(creds, "clientSecret", "OPPO"); + String packageName = requirePackageName(v); + String token = oppoGetToken(clientId, clientSecret); + Map params = new LinkedHashMap<>(); + params.put("pkg_name", packageName); + params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode()))); + String url = oppoRequestUrl( + "https://oop-openapi-cn.heytapmobi.com/developer/v1/app/cancel-audit-info", + params, token, true, clientSecret); + ResponseEntity resp = rest.getForEntity(url, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody())); + oppoCheckSuccess(root, "cancel-audit"); + log.info("OPPO cancel-audit called for pkg={}", packageName); + } + + private void cancelAtVivo(AppVersionEntity v, Map creds) throws Exception { + String accessKey = require(creds, "accessKey", "VIVO"); + String accessSecret = require(creds, "accessSecret", "VIVO"); + String packageName = requirePackageName(v); + Map params = new LinkedHashMap<>(); + params.put("packageName", packageName); + String url = vivoRequestUrl(accessKey, accessSecret, "app.sync.withdraw.app", params); + ResponseEntity resp = rest.getForEntity(url, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody())); + vivoCheckSuccess(root, "撤回"); + log.info("VIVO withdraw called for pkg={}", packageName); + } + + // ── Modify publish schedule ─────────────────────────────────────────────── + + /** + * Change when the approved version goes live. + * publishType: "IMMEDIATE" or "SCHEDULED" + * scheduledAt: required when publishType == "SCHEDULED" + */ + public AppVersionEntity updatePublishSchedule(String versionId, String publishType, + java.time.LocalDateTime scheduledAt) { + AppVersionEntity v = versionRepo.findById(versionId) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId)); + if ("IMMEDIATE".equalsIgnoreCase(publishType)) { + v.setScheduledPublishAt(null); + } else if ("SCHEDULED".equalsIgnoreCase(publishType)) { + if (scheduledAt == null) throw new IllegalArgumentException("scheduledAt required for SCHEDULED publish"); + v.setScheduledPublishAt(scheduledAt); + } + // Try to call HUAWEI API to update release plan if token available + try { + AppStoreConfigEntity cfg = configRepo + .findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.HUAWEI) + .orElse(null); + if (cfg != null && cfg.isEnabled()) { + Map creds = parseConfig(cfg.getConfigJson()); + huaweiUpdateReleasePlan(v, creds, publishType, scheduledAt); + } + } catch (Exception e) { + log.warn("HUAWEI release plan update failed (non-fatal): {}", e.getMessage()); + } + return versionRepo.save(v); + } + + private void huaweiUpdateReleasePlan(AppVersionEntity v, Map creds, + String publishType, java.time.LocalDateTime scheduledAt) throws Exception { + String clientId = require(creds, "clientId", "HUAWEI"); + String clientSecret = require(creds, "clientSecret", "HUAWEI"); + String token = huaweiGetToken(clientId, clientSecret); + String hwAppId = huaweiGetAppId(clientId, token, v.getPackageName()); + HttpHeaders headers = huaweiHeaders(clientId, token); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = new LinkedHashMap<>(); + if ("IMMEDIATE".equalsIgnoreCase(publishType)) { + body.put("releaseType", 1); + } else { + body.put("releaseType", 3); + body.put("releaseTime", scheduledAt.format( + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + rest.exchange(HUAWEI_API + "/api/publish/v2/app-info?appId=" + hwAppId, + HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class); + log.info("HUAWEI release plan updated: type={} for appId={}", publishType, hwAppId); + } + // ── Utilities ───────────────────────────────────────────────────────────── private JsonNode miGetAppInfo(String account,