From f7dbce7268ede0d7ff6868dbbdca62943074ee44 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sun, 17 May 2026 12:15:19 +0800 Subject: [PATCH] fix HONOR poll endpoint and status mapping; improve all store polling reliability - HONOR: use get-app-current-release endpoint (correct), auditResult field (0=review,1=approved,2=rejected) - HONOR: assertHonorSuccess now accepts both "0" and "0000" success codes - OPPO: add integer status mapping (111=approved, 444=rejected) from reference impl - All stores: add full response body logging for diagnosing poll issues Co-Authored-By: Claude Sonnet 4.6 --- .../service/ImPlatformEventService.java | 43 +++ .../repository/AppVersionRepository.java | 3 + .../xuqm/update/service/AppStoreService.java | 58 ++-- .../update/service/StoreReviewImNotifier.java | 4 +- .../service/StoreSubmissionService.java | 270 +++++++++++++++++- 5 files changed, 349 insertions(+), 29 deletions(-) diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java index a41d0cf..0f13160 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java @@ -52,6 +52,42 @@ public class ImPlatformEventService { return result; } + public Map notifyServiceActivationChange(ServiceActivationEventRequest request) throws Exception { + AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey); + String recipientUserId = platformEventsRecipientUserId(); + String senderUserId = platformEventsAdminUserId(); + String senderToken = requestImToken(platformApp, senderUserId); + XuqmImServerSdk sdk = sdk(platformApp, senderToken); + + Map contentPayload = new LinkedHashMap<>(); + contentPayload.put("event", "service_activation_update"); + contentPayload.put("appKey", request.appKey()); + contentPayload.put("serviceType", request.serviceType() == null ? "" : request.serviceType()); + contentPayload.put("status", request.status() == null ? "" : request.status()); + contentPayload.put("reviewNote", request.reviewNote() == null ? "" : request.reviewNote()); + contentPayload.put("timestamp", System.currentTimeMillis()); + String content = objectMapper.writeValueAsString(contentPayload); + + log.info("IM service activation event send platformAppKey={} recipient={} appKey={} serviceType={} status={}", + platformApp.getAppKey(), recipientUserId, request.appKey(), request.serviceType(), request.status()); + var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest( + UUID.randomUUID().toString(), + recipientUserId, + "SINGLE", + "NOTIFY", + content, + null + )); + log.info("IM service activation event sent platformAppKey={} recipient={} messageId={}", + platformApp.getAppKey(), recipientUserId, message.id()); + + Map result = new LinkedHashMap<>(); + result.put("appKey", platformApp.getAppKey()); + result.put("userId", recipientUserId); + result.put("messageId", message.id()); + return result; + } + public Map notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception { AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey); String recipientUserId = platformEventsRecipientUserId(); @@ -138,4 +174,11 @@ public class ImPlatformEventService { String event, String source ) {} + + public record ServiceActivationEventRequest( + String appKey, + String serviceType, + String status, + String reviewNote + ) {} } 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 a045cb6..9d83f45 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 @@ -32,4 +32,7 @@ public interface AppVersionRepository extends JpaRepository findAllWithSubmittingStores(); + + @Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true) + List findAllWithUnderReviewStores(); } 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 ebd72d1..0cf1727 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 @@ -440,15 +440,18 @@ public class AppStoreService { private String buildWebhookBody(String notifyType, AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) throws Exception { String stateLabel = switch (state) { + case PENDING -> "排队中"; + case SUBMITTING -> "提交中"; case UNDER_REVIEW -> "审核中"; - case APPROVED -> "已通过"; - case REJECTED -> "已拒绝"; - default -> state.name(); + case APPROVED -> "已通过"; + case REJECTED -> "已拒绝"; + case WITHDRAWN -> "已撤回"; }; - String storeLabel = storeType; - String text = String.format("【应用审核通知】%s - %s %s%s", - v.getVersionName(), storeLabel, stateLabel, - (reason != null && !reason.isBlank()) ? ":" + reason : ""); + String storeLabel = storeDisplayName(storeType); + String reasonSuffix = (reason != null && !reason.isBlank()) ? "\n原因:" + reason : ""; + String text = String.format("【应用审核通知】\n应用:%s\n版本:%s(%d)\n渠道:%s\n状态:%s%s", + v.getAppKey(), v.getVersionName(), v.getVersionCode(), + storeLabel, stateLabel, reasonSuffix); return switch (notifyType.toUpperCase()) { case "DINGTALK" -> mapper.writeValueAsString(Map.of( @@ -460,16 +463,37 @@ public class AppStoreService { case "FEISHU" -> mapper.writeValueAsString(Map.of( "msg_type", "text", "content", Map.of("text", text))); - default -> mapper.writeValueAsString(Map.of( - "event", "store_review_update", - "versionId", v.getId(), - "appKey", v.getAppKey(), - "versionName", v.getVersionName(), - "storeType", storeType, - "reviewState", state.name(), - "reviewReason", reason == null ? "" : reason, - "publishStatus", v.getPublishStatus().name(), - "timestamp", System.currentTimeMillis())); + default -> { + Map payload = new LinkedHashMap<>(); + payload.put("event", "store_review_update"); + payload.put("versionId", v.getId()); + payload.put("appKey", v.getAppKey()); + payload.put("versionName", v.getVersionName()); + payload.put("versionCode", v.getVersionCode()); + payload.put("storeType", storeType); + payload.put("storeDisplayName", storeLabel); + payload.put("reviewState", state.name()); + payload.put("reviewStateLabel", stateLabel); + payload.put("reviewReason", reason == null ? "" : reason); + payload.put("publishStatus", v.getPublishStatus().name()); + payload.put("timestamp", System.currentTimeMillis()); + yield mapper.writeValueAsString(payload); + } + }; + } + + private static String storeDisplayName(String storeType) { + if (storeType == null) return ""; + return switch (storeType.toUpperCase()) { + case "MI" -> "小米应用商店"; + case "HUAWEI" -> "华为应用市场"; + case "HONOR" -> "荣耀应用市场"; + case "OPPO" -> "OPPO应用商店"; + case "VIVO" -> "vivo应用商店"; + case "APP_STORE" -> "App Store"; + case "GOOGLE_PLAY" -> "Google Play"; + case "HARMONY_APP" -> "鸿蒙应用市场"; + default -> storeType; }; } diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java index c4e7968..9314e5d 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java @@ -46,8 +46,8 @@ public class StoreReviewImNotifier { payload.put("stage", stage == null ? "" : stage); payload.put("batchId", batchId == null ? "" : batchId); payload.put("publishStatus", publishStatus == null ? "" : publishStatus); - payload.put("event", event == null || event.isBlank() ? "store_review_update" : event); - payload.put("source", "update-service"); + payload.put("event", "store_review_update"); + payload.put("source", event == null || event.isBlank() ? "update-service" : event); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(trimTrailingSlash(tenantServiceUrl) + "/api/internal/im/platform-events/notify")) 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 6c2a002..78b5074 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 @@ -137,6 +137,13 @@ public class StoreSubmissionService { AtomicInteger successCount = new AtomicInteger(); AtomicInteger rejectedCount = new AtomicInteger(); AtomicInteger skippedCount = new AtomicInteger(); + boolean parallel = publishConfigService.getConfigNode(v.getAppKey()) + .path("parallelStoreUpload").asBoolean(true); + log.info("Store submit mode: {} version={} batchId={}", parallel ? "PARALLEL" : "SEQUENTIAL", versionId, batchId); + // Sequential mode: preflight marks waiting stores as QUEUED so the UI shows 排队 + // and the uploading store transitions to SUBMITTING right before its upload starts. + // Parallel mode: all stores go straight to SUBMITTING because all uploads fire at once. + String preflightStage = parallel ? "SUBMITTING" : "QUEUED"; List plans = new ArrayList<>(); for (int index = 0; index < targets.size(); index++) { String storeType = targets.get(index); @@ -181,14 +188,14 @@ public class StoreSubmissionService { } Map creds = parseConfig(cfg.getConfigJson()); try { - storeService.updateStoreSubmissionStage(versionId, storeType, "SUBMITTING", - "开始调用厂商提交接口", batchId); + storeService.updateStoreSubmissionStage(versionId, storeType, preflightStage, + parallel ? "开始调用厂商提交接口" : "排队等待上传", batchId); } catch (Exception stageEx) { log.warn("Failed to update submission stage for {}/{} batchId={}: {}", v.getAppKey(), storeType, batchId, stageEx.getMessage(), stageEx); } recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_STAGE", Map.of( - "phase", "SUBMITTING", + "phase", preflightStage, "credentialKeys", new ArrayList<>(creds.keySet()) ), null); plans.add(new SubmissionPlan(storeType, creds, storeStartedAt)); @@ -212,9 +219,6 @@ public class StoreSubmissionService { } } } - boolean parallel = publishConfigService.getConfigNode(v.getAppKey()) - .path("parallelStoreUpload").asBoolean(true); - log.info("Store submit mode: {} version={} batchId={}", parallel ? "PARALLEL" : "SEQUENTIAL", versionId, batchId); if (parallel) { List> futures = plans.stream() @@ -233,8 +237,71 @@ public class StoreSubmissionService { } } } else { + // Upload actions are strictly serial; DB/event post-processing fires async so the + // next store's upload starts the moment the previous upload API call returns. + List> postFutures = new ArrayList<>(); for (SubmissionPlan plan : plans) { - executeSinglePlan(plan, v, apkFile, versionId, batchId, successCount, rejectedCount); + final String storeType = plan.storeType; + final long storeStartedAt = plan.storeStartedAt; + // Transition this store from QUEUED (排队) → SUBMITTING (上传) right before upload begins + try { + storeService.updateStoreSubmissionStage(versionId, storeType, "SUBMITTING", + "开始上传", batchId); + } catch (Exception stageEx) { + log.warn("Failed to update to SUBMITTING stage for {}/{} batchId={}: {}", + v.getAppKey(), storeType, batchId, stageEx.getMessage()); + } + try { + submitToStore(storeType, v, apkFile, plan.creds); + postFutures.add(CompletableFuture.runAsync(() -> { + try { + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.UNDER_REVIEW); + successCount.incrementAndGet(); + recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of( + "durationMs", System.currentTimeMillis() - storeStartedAt, + "reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name() + ), null); + log.info("Submitted version {} to {}", versionId, storeType); + } catch (Exception ex) { + log.warn("Post-processing failed for {}/{}: {}", versionId, storeType, ex.getMessage(), ex); + } + })); + } catch (Exception e) { + rejectedCount.incrementAndGet(); + final String message = describeException(e); + log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e); + postFutures.add(CompletableFuture.runAsync(() -> { + try { + recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( + "durationMs", System.currentTimeMillis() - storeStartedAt, + "phase", "SUBMISSION", + "errorClass", e.getClass().getName(), + "reason", message + ), message); + } catch (Exception logEx) { + log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), storeType, logEx.getMessage()); + } + try { + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.REJECTED, + message.length() > 500 ? message.substring(0, 500) : message); + } catch (Exception ex) { + log.warn("Failed to persist rejection for {}/{} batchId={}: {}", + v.getAppKey(), storeType, batchId, ex.getMessage(), ex); + } + })); + } + } + // Wait for all post-processing before the batch-end sweep so the sweep does not + // incorrectly mark a store as FAILED while its UNDER_REVIEW write is still in flight. + if (!postFutures.isEmpty()) { + try { + CompletableFuture.allOf(postFutures.toArray(new CompletableFuture[0])) + .get(10, java.util.concurrent.TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("Sequential post-processing wait interrupted for version={}: {}", versionId, e.getMessage()); + } } } sweepStuckSubmittingForBatch(versionId, batchId); @@ -259,6 +326,185 @@ public class StoreSubmissionService { } } + /** + * Poll vendor APIs every 10 minutes for versions still UNDER_REVIEW, so the system + * detects vendor-side approval / rejection without waiting for a push from the store. + * Each store's query runs in a short-lived thread; failures are logged and skipped. + */ + @Scheduled(fixedDelay = 10 * 60_000) + public void pollStoreReviewStatus() { + List candidates = versionRepo.findAllWithUnderReviewStores(); + if (candidates.isEmpty()) return; + log.info("Store review poll: checking {} version(s) with UNDER_REVIEW stores", candidates.size()); + for (AppVersionEntity v : candidates) { + Map reviewMap; + try { + reviewMap = mapper.readValue(v.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {}); + } catch (Exception e) { + continue; + } + for (Map.Entry entry : new ArrayList<>(reviewMap.entrySet())) { + String storeType = entry.getKey(); + @SuppressWarnings("unchecked") + Map info = entry.getValue() instanceof Map m ? (Map) m : null; + if (info == null) continue; + String state = info.get("state") instanceof String s ? s : ""; + if (!"UNDER_REVIEW".equals(state)) continue; + AppStoreConfigEntity cfg; + try { + cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(), + AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null); + } catch (Exception e) { + continue; + } + if (cfg == null || !cfg.isEnabled()) continue; + try { + Map creds = parseConfig(cfg.getConfigJson()); + AppVersionEntity.StoreReviewState polled = pollStoreSingleReviewState(storeType, v, creds); + if (polled != null && polled != AppVersionEntity.StoreReviewState.UNDER_REVIEW) { + log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, polled); + storeService.updateStoreReview(v.getId(), storeType, polled, + "厂商审核状态轮询检测"); + } else if (polled == null) { + log.debug("Store review poll: {}/{} returned null (no poll API for this store)", v.getId(), storeType); + } else { + log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType); + } + } catch (Exception e) { + log.warn("Store review poll error for {}/{}: {}", v.getId(), storeType, e.getMessage()); + } + } + } + } + + @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={} for version={}", status, 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); + JsonNode pkg = root.path("packageInfo"); + // Log the full packageInfo so we can tune status-field mapping from production logs. + log.info("MI poll packageInfo for version={}: {}", v.getId(), pkg); + // Xiaomi /devupload/dev/query response: + // packageInfo.appStatus (or .status): 1=待审核, 2=审核中, 3=已发布/上线, 4=已下线, 5=拒绝 + // Fallback candidate: .synchroResult (1=成功/已上线, 0=失败/拒绝) + int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1)); + log.info("MI poll appStatus={} for version={}", appStatus, v.getId()); + yield switch (appStatus) { + case 3 -> AppVersionEntity.StoreReviewState.APPROVED; + case 5 -> AppVersionEntity.StoreReviewState.REJECTED; + case 1, 2 -> AppVersionEntity.StoreReviewState.UNDER_REVIEW; + default -> { + // Fall back to synchroResult: 1=已上线(通过), -1/0=其他 + int synchroResult = pkg.path("synchroResult").asInt(-1); + log.info("MI poll synchroResult={} for version={}", synchroResult, v.getId()); + yield synchroResult == 1 + ? AppVersionEntity.StoreReviewState.APPROVED + : AppVersionEntity.StoreReviewState.UNDER_REVIEW; + } + }; + } + default -> null; // APP_STORE, GOOGLE_PLAY: no vendor query API + }; + } + /** * On startup, mark any stores stuck in SUBMITTING as FAILED. * A SUBMITTING state with no corresponding active thread means the service was @@ -774,9 +1020,13 @@ public class StoreSubmissionService { private void assertHonorSuccess(Map body, String step) { if (body == null) throw new RuntimeException("Honor: empty response for " + step); Object code = body.get("code"); - if (code == null || !"0".equals(String.valueOf(code))) { - String msg = body.get("msg") != null ? body.get("msg").toString() : "unknown error"; - throw new RuntimeException("Honor " + step + " failed: " + msg); + String codeStr = code == null ? "" : String.valueOf(code).trim(); + // HONOR API uses "0" or "0000" as success code depending on the endpoint + boolean success = "0".equals(codeStr) || "0000".equals(codeStr); + if (!success) { + String msg = body.get("msg") != null ? body.get("msg").toString() + : body.get("message") != null ? body.get("message").toString() : "unknown error"; + throw new RuntimeException("Honor " + step + " failed: code=" + codeStr + ", msg=" + msg); } }