fix: store resubmission, Xiaomi curl, and live-on-store detection

- Dockerfile: add curl to Alpine runtime image (required by Xiaomi/Vivo
  APK upload which uses ProcessBuilder curl for HTTP/1.1 multipart)
- AppStoreService: mark previously-APPROVED stores as WITHDRAWN on
  resubmission so executeSubmitAsync calls the store cancel API first;
  guard updateStoreReview from stale APPROVED webhooks on PENDING/WITHDRAWN
  stores; add updateStoreReviewLive() to record pre-existing live versions
  with liveOnStore/preExisting metadata
- StoreSubmissionService: call cancelAtStore for WITHDRAWN stores before
  resubmitting; expand poll query to include REJECTED stores and call
  updateStoreReviewLive when a REJECTED store is found live; after any
  submission failure check if the store already has the version live
  (catches Huawei/Vivo pre-existing direct uploads at submission time)
- AppVersionRepository: add findAllWithUnderReviewOrRejectedStores query

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

查看文件

@ -41,6 +41,10 @@ FROM --platform=linux/amd64 eclipse-temurin:21-jre-alpine
WORKDIR /app
ARG SERVICE_MODULE
# curl is required by update-service (MI store upload uses ProcessBuilder curl to handle
# Expect:100-continue and force HTTP/1.1 for large multipart uploads)
RUN apk add --no-cache curl
COPY --from=build /workspace/${SERVICE_MODULE}/target/${SERVICE_MODULE}-0.1.0-SNAPSHOT.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

查看文件

@ -36,6 +36,10 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true)
List<AppVersionEntity> findAllWithUnderReviewStores();
// Also used by poll to detect pre-existing live versions on REJECTED stores
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%' OR store_review_status LIKE '%REJECTED%'", nativeQuery = true)
List<AppVersionEntity> findAllWithUnderReviewOrRejectedStores();
List<AppVersionEntity> findByAppKeyAndPlatformAndVersionCodeAndIdNot(
String appKey, AppVersionEntity.Platform platform, int versionCode, String id);
}

查看文件

@ -356,6 +356,64 @@ public class AppStoreService {
}
}
/**
* Mark a store as already-live (APPROVED) detected via poll or on-failure check.
* Sets liveOnStore=true and, if preExisting=true, marks that the version was published
* directly to the store (not via the platform submission flow).
*/
public AppVersionEntity updateStoreReviewLive(String versionId,
String storeType,
boolean preExisting,
String reason) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
// Use the same guard as updateStoreReview but for liveOnStore we allow
// PENDING and WITHDRAWN transitions because detection happens BEFORE submission.
// (If the platform never successfully submitted, the store had it from elsewhere.)
Map<String, Object> current = asReviewPayload(reviewMap.get(storeType));
String batchId = readText(current.get("batchId"));
String submittedAt = readText(current.get("submittedAt"));
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("liveOnStore", true);
if (preExisting) {
extra.put("preExisting", true);
}
reviewMap.put(storeType, reviewPayload(
AppVersionEntity.StoreReviewState.APPROVED.name(),
reason == null ? "版本已在应用商店上线" : reason,
"APPROVED",
batchId.isBlank() ? null : batchId,
submittedAt.isBlank() ? null : submittedAt,
LocalDateTime.now().toString(),
extra));
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()));
}
AppVersionEntity saved = versionRepo.save(v);
operationLogService.record(saved.getAppKey(), "APP_VERSION", saved.getId(),
"STORE_LIVE_DETECTED", reason,
Map.of("storeType", storeType,
"preExisting", preExisting,
"publishStatus", saved.getPublishStatus().name()));
storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(), saved.getId(),
storeType, AppVersionEntity.StoreReviewState.APPROVED.name(),
reason, "APPROVED", batchId, saved.getPublishStatus().name(),
"store_live_detected");
return saved;
}
}
public AppVersionEntity updateStoreSubmissionStage(String versionId,
String storeType,
String stage,
@ -618,6 +676,16 @@ public class AppStoreService {
String batchId,
String submittedAt,
String updatedAt) {
return reviewPayload(state, reason, stage, batchId, submittedAt, updatedAt, null);
}
private Map<String, Object> reviewPayload(String state,
String reason,
String stage,
String batchId,
String submittedAt,
String updatedAt,
Map<String, Object> extra) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("state", state);
payload.put("reason", reason == null ? "" : reason);
@ -625,6 +693,9 @@ public class AppStoreService {
payload.put("batchId", batchId == null ? "" : batchId);
payload.put("submittedAt", submittedAt == null ? "" : submittedAt);
payload.put("updatedAt", updatedAt == null ? "" : updatedAt);
if (extra != null) {
payload.putAll(extra);
}
return payload;
}

查看文件

@ -308,12 +308,30 @@ public class StoreSubmissionService {
} catch (Exception logEx) {
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), storeType, logEx.getMessage());
}
boolean seqAlreadyLive = false;
try {
storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED,
message.length() > 500 ? message.substring(0, 500) : message);
AppStoreConfigEntity liveCfg = configRepo
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
.orElse(null);
if (liveCfg != null && liveCfg.isEnabled()) {
Map<String, String> liveCreds = parseConfig(liveCfg.getConfigJson());
AppVersionEntity.StoreReviewState liveState = pollStoreSingleReviewState(storeType, v, liveCreds);
seqAlreadyLive = (liveState == AppVersionEntity.StoreReviewState.APPROVED);
}
} catch (Exception pollEx) {
log.debug("Post-failure live-check for {}/{} failed: {}", storeType, versionId, pollEx.getMessage());
}
try {
if (seqAlreadyLive) {
storeService.updateStoreReviewLive(versionId, storeType, true,
"版本已在应用商店上线(提交时检测到,可能为直接上传)");
} else {
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={}: {}",
log.warn("Failed to persist rejection/live-status for {}/{} batchId={}: {}",
v.getAppKey(), storeType, batchId, ex.getMessage(), ex);
}
}));
@ -353,15 +371,17 @@ 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.
* Poll vendor APIs every 10 minutes for versions with UNDER_REVIEW or REJECTED stores.
*
* UNDER_REVIEW: detect approval/rejection without waiting for a store webhook push.
* REJECTED: detect if the store already has the version live (pre-existing direct upload
* by the developer bypassing the platform), and update to 已上线 instead of leaving REJECTED.
*/
@Scheduled(fixedDelay = 10 * 60_000)
public void pollStoreReviewStatus() {
List<AppVersionEntity> candidates = versionRepo.findAllWithUnderReviewStores();
List<AppVersionEntity> candidates = versionRepo.findAllWithUnderReviewOrRejectedStores();
if (candidates.isEmpty()) return;
log.info("Store review poll: checking {} version(s) with UNDER_REVIEW stores", candidates.size());
log.info("Store review poll: checking {} version(s) with UNDER_REVIEW/REJECTED stores", candidates.size());
for (AppVersionEntity v : candidates) {
Map<String, Object> reviewMap;
try {
@ -375,7 +395,9 @@ public class StoreSubmissionService {
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;
boolean isUnderReview = "UNDER_REVIEW".equals(state);
boolean isRejected = "REJECTED".equals(state);
if (!isUnderReview && !isRejected) continue;
AppStoreConfigEntity cfg;
try {
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(),
@ -387,14 +409,27 @@ public class StoreSubmissionService {
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) {
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, "厂商审核状态轮询检测:版本已上线");
} else {
storeService.updateStoreReview(v.getId(), storeType, polled, "厂商审核状态轮询检测");
}
} else {
log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType);
}
} else {
log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType);
// REJECTED store check if it's actually live (pre-existing direct upload)
if (polled == AppVersionEntity.StoreReviewState.APPROVED) {
log.info("Store review poll: {}/{} was REJECTED but is now live on store — marking pre-existing", v.getId(), storeType);
storeService.updateStoreReviewLive(v.getId(), storeType, true,
"版本已在应用商店上线(提交失败但检测到相同版本已上线,可能为直接上传)");
}
// otherwise leave REJECTED as-is
}
} catch (Exception e) {
log.warn("Store review poll error for {}/{}: {}", v.getId(), storeType, e.getMessage());
@ -636,12 +671,30 @@ public class StoreSubmissionService {
} catch (Exception logEx) {
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage());
}
boolean alreadyLive = false;
try {
storeService.updateStoreReview(versionId, plan.storeType,
AppVersionEntity.StoreReviewState.REJECTED,
message.length() > 500 ? message.substring(0, 500) : message);
AppStoreConfigEntity liveCfg = configRepo
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(plan.storeType))
.orElse(null);
if (liveCfg != null && liveCfg.isEnabled()) {
Map<String, String> liveCreds = parseConfig(liveCfg.getConfigJson());
AppVersionEntity.StoreReviewState liveState = pollStoreSingleReviewState(plan.storeType, v, liveCreds);
alreadyLive = (liveState == AppVersionEntity.StoreReviewState.APPROVED);
}
} catch (Exception pollEx) {
log.debug("Post-failure live-check for {}/{} failed: {}", plan.storeType, versionId, pollEx.getMessage());
}
try {
if (alreadyLive) {
storeService.updateStoreReviewLive(versionId, plan.storeType, true,
"版本已在应用商店上线(提交时检测到,可能为直接上传)");
} else {
storeService.updateStoreReview(versionId, plan.storeType,
AppVersionEntity.StoreReviewState.REJECTED,
message.length() > 500 ? message.substring(0, 500) : message);
}
} catch (Exception ex) {
log.warn("Failed to persist rejection for {}/{} batchId={}: {}",
log.warn("Failed to persist rejection/live-status for {}/{} batchId={}: {}",
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
}
}