fix: withdraw previously-approved store reviews before re-submitting same version

When re-submitting a version to stores where the previous review was APPROVED:

1. markSubmitted now sets those stores to WITHDRAWN (not PENDING), preserving
   the old batchId/submittedAt for traceability. This signals to executeSubmitAsync
   that the store cancel API must be called before a new submission is attempted.

2. executeSubmitAsync detects the WITHDRAWN state and calls cancelAtStore first,
   then falls through to the normal submission path. This revokes the old approval
   on the store's side so no stale webhook or poll cycle can fire APPROVED for the
   old review after re-submission.

3. updateStoreReview now rejects APPROVED transitions from PENDING or WITHDRAWN
   states (stale webhook guard). A valid approval can only arrive after the store
   has seen the new submission (i.e. current state must be SUBMITTING or UNDER_REVIEW).
   This prevents autoPublishAfterReview from triggering before the new review cycle.

Operation log includes `approvedWithdrawn` list when any store was withdrawn on re-submit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-05-18 17:09:31 +08:00
父节点 ab7f029960
当前提交 8de0338b93
共有 2 个文件被更改,包括 69 次插入14 次删除

查看文件

@ -148,16 +148,29 @@ public class AppStoreService {
// Withdraw lower versions' active reviews for the same stores // Withdraw lower versions' active reviews for the same stores
cancelSupersededVersionReviews(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets); cancelSupersededVersionReviews(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets);
// Merge with existing store statuses instead of replacing preserves other stores' states // Merge with existing store statuses instead of replacing preserves other stores' states.
// If a store was previously APPROVED, mark it WITHDRAWN so executeSubmitAsync knows to
// call the store's cancel API before re-submitting. This prevents stale APPROVED webhooks
// or poll cycles from triggering auto-publish before the new review cycle completes.
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus()); Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
List<String> approvedWithdrawn = new ArrayList<>();
for (String store : resolvedTargets) { for (String store : resolvedTargets) {
reviewMap.put(store, reviewPayload( String existingState = readReviewState(reviewMap.get(store));
AppVersionEntity.StoreReviewState.PENDING.name(), if (AppVersionEntity.StoreReviewState.APPROVED.name().equals(existingState)) {
null, Map<String, Object> prev = asReviewPayload(reviewMap.get(store));
"QUEUED", reviewMap.put(store, reviewPayload(
null, AppVersionEntity.StoreReviewState.WITHDRAWN.name(),
null, "重新提审,待撤回原有审核通过状态",
LocalDateTime.now().toString())); "PENDING_CANCEL",
readText(prev.get("batchId")).isBlank() ? null : readText(prev.get("batchId")),
readText(prev.get("submittedAt")).isBlank() ? null : readText(prev.get("submittedAt")),
LocalDateTime.now().toString()));
approvedWithdrawn.add(store);
} else {
reviewMap.put(store, reviewPayload(
AppVersionEntity.StoreReviewState.PENDING.name(),
null, "QUEUED", null, null, LocalDateTime.now().toString()));
}
} }
v.setStoreSubmitTargets(mapper.writeValueAsString(resolvedTargets)); v.setStoreSubmitTargets(mapper.writeValueAsString(resolvedTargets));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
@ -170,18 +183,21 @@ public class AppStoreService {
v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode)); v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode));
} }
AppVersionEntity saved = versionRepo.save(v); AppVersionEntity saved = versionRepo.save(v);
Map<String, Object> submitLogDetails = new LinkedHashMap<>();
submitLogDetails.put("storeTypes", resolvedTargets);
submitLogDetails.put("submitMode", saved.getStoreSubmitMode());
submitLogDetails.put("scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString());
submitLogDetails.put("autoPublishAfterReview", saved.isAutoPublishAfterReview());
if (!approvedWithdrawn.isEmpty()) {
submitLogDetails.put("approvedWithdrawn", approvedWithdrawn);
}
operationLogService.record( operationLogService.record(
saved.getAppKey(), saved.getAppKey(),
"APP_VERSION", "APP_VERSION",
saved.getId(), saved.getId(),
"STORE_SUBMIT_REQUEST", "STORE_SUBMIT_REQUEST",
null, null,
Map.of( submitLogDetails);
"storeTypes", resolvedTargets,
"submitMode", saved.getStoreSubmitMode(),
"scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(),
"autoPublishAfterReview", saved.isAutoPublishAfterReview()
));
storeReviewImNotifier.notifyStoreReviewChange( storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(), saved.getAppKey(),
saved.getId(), saved.getId(),
@ -268,6 +284,21 @@ public class AppStoreService {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus()); Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
// Guard: reject APPROVED transitions from PENDING or WITHDRAWN states.
// Those states mean a re-submission is queued or in progress; accepting APPROVED
// here would be a stale webhook from the previous review cycle, which would
// incorrectly trigger auto-publish before the new review completes.
if (state == AppVersionEntity.StoreReviewState.APPROVED) {
String existingState = readReviewState(reviewMap.get(storeType));
if (AppVersionEntity.StoreReviewState.PENDING.name().equals(existingState)
|| AppVersionEntity.StoreReviewState.WITHDRAWN.name().equals(existingState)) {
log.warn("Ignored stale APPROVED event for {}/{} (current: {}); re-submission is pending",
versionId, storeType, existingState);
return v;
}
}
Map<String, Object> current = asReviewPayload(reviewMap.get(storeType)); Map<String, Object> current = asReviewPayload(reviewMap.get(storeType));
String batchId = readText(current.get("batchId")); String batchId = readText(current.get("batchId"));
String submittedAt = readText(current.get("submittedAt")); String submittedAt = readText(current.get("submittedAt"));

查看文件

@ -171,6 +171,30 @@ public class StoreSubmissionService {
continue; continue;
} }
// WITHDRAWN means this store had a previous APPROVED state that markSubmitted reset.
// Call the store's cancel API to revoke the old approval before re-submitting, so
// no stale webhooks or poll cycles can trigger auto-publish from the old review.
if ("WITHDRAWN".equals(activeState)) {
AppStoreConfigEntity withdrawCfg = configRepo
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
.orElse(null);
if (withdrawCfg != null && withdrawCfg.isEnabled()) {
try {
Map<String, String> withdrawCreds = parseConfig(withdrawCfg.getConfigJson());
cancelAtStore(storeType, v, withdrawCreds);
log.info("Withdrew previously-approved store {} for version {} before re-submitting",
storeType, versionId);
recordStoreEvent(v, versionId, batchId, storeType, "STORE_WITHDRAW_BEFORE_RESUBMIT", Map.of(
"reason", "重新提审,撤回原有审核通过状态"
), null);
} catch (Exception e) {
log.warn("Cancel-before-resubmit failed for {}/{} batchId={}: {}",
storeType, versionId, batchId, e.getMessage());
}
}
// Fall through to the normal submission path below
}
AppStoreConfigEntity cfg = configRepo AppStoreConfigEntity cfg = configRepo
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType)) .findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
.orElse(null); .orElse(null);