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 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-17 12:15:19 +08:00
父节点 8dea96ef5e
当前提交 f7dbce7268
共有 5 个文件被更改,包括 349 次插入29 次删除

查看文件

@ -52,6 +52,42 @@ public class ImPlatformEventService {
return result;
}
public Map<String, String> 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<String, Object> 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<String, String> result = new LinkedHashMap<>();
result.put("appKey", platformApp.getAppKey());
result.put("userId", recipientUserId);
result.put("messageId", message.id());
return result;
}
public Map<String, String> 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
) {}
}

查看文件

@ -32,4 +32,7 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%SUBMITTING%'", nativeQuery = true)
List<AppVersionEntity> findAllWithSubmittingStores();
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true)
List<AppVersionEntity> findAllWithUnderReviewStores();
}

查看文件

@ -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<String, Object> 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;
};
}

查看文件

@ -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"))

查看文件

@ -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<SubmissionPlan> plans = new ArrayList<>();
for (int index = 0; index < targets.size(); index++) {
String storeType = targets.get(index);
@ -181,14 +188,14 @@ public class StoreSubmissionService {
}
Map<String, String> 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<CompletableFuture<Void>> 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<CompletableFuture<Void>> 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<AppVersionEntity> 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<String, Object> reviewMap;
try {
reviewMap = mapper.readValue(v.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
continue;
}
for (Map.Entry<String, Object> entry : new ArrayList<>(reviewMap.entrySet())) {
String storeType = entry.getKey();
@SuppressWarnings("unchecked")
Map<String, Object> info = entry.getValue() instanceof Map<?, ?> m ? (Map<String, Object>) 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<String, String> 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<String, String> 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<String> 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<java.util.Map> 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<String, Object> body = requireBodyMap(resp.getBody(), "HUAWEI app-info poll");
Map<String, Object> appInfoMap = body.get("appInfo") instanceof Map<?,?> m
? (Map<String, Object>) 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<java.util.Map> resp = rest.exchange(url, org.springframework.http.HttpMethod.GET,
new HttpEntity<>(headers), java.util.Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> 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<String, Object> data = body.get("data") instanceof Map<?, ?> d
? (Map<String, Object>) 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<String, Object> 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);
}
}