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>
这个提交包含在:
父节点
8de0338b93
当前提交
8d46d21726
@ -41,6 +41,10 @@ FROM --platform=linux/amd64 eclipse-temurin:21-jre-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG SERVICE_MODULE
|
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
|
COPY --from=build /workspace/${SERVICE_MODULE}/target/${SERVICE_MODULE}-0.1.0-SNAPSHOT.jar /app/app.jar
|
||||||
|
|
||||||
ENTRYPOINT ["java", "-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)
|
@Query(value = "SELECT * FROM update_app_version WHERE store_review_status LIKE '%UNDER_REVIEW%'", nativeQuery = true)
|
||||||
List<AppVersionEntity> findAllWithUnderReviewStores();
|
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(
|
List<AppVersionEntity> findByAppKeyAndPlatformAndVersionCodeAndIdNot(
|
||||||
String appKey, AppVersionEntity.Platform platform, int versionCode, String id);
|
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,
|
public AppVersionEntity updateStoreSubmissionStage(String versionId,
|
||||||
String storeType,
|
String storeType,
|
||||||
String stage,
|
String stage,
|
||||||
@ -618,6 +676,16 @@ public class AppStoreService {
|
|||||||
String batchId,
|
String batchId,
|
||||||
String submittedAt,
|
String submittedAt,
|
||||||
String updatedAt) {
|
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<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("state", state);
|
payload.put("state", state);
|
||||||
payload.put("reason", reason == null ? "" : reason);
|
payload.put("reason", reason == null ? "" : reason);
|
||||||
@ -625,6 +693,9 @@ public class AppStoreService {
|
|||||||
payload.put("batchId", batchId == null ? "" : batchId);
|
payload.put("batchId", batchId == null ? "" : batchId);
|
||||||
payload.put("submittedAt", submittedAt == null ? "" : submittedAt);
|
payload.put("submittedAt", submittedAt == null ? "" : submittedAt);
|
||||||
payload.put("updatedAt", updatedAt == null ? "" : updatedAt);
|
payload.put("updatedAt", updatedAt == null ? "" : updatedAt);
|
||||||
|
if (extra != null) {
|
||||||
|
payload.putAll(extra);
|
||||||
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -308,12 +308,30 @@ public class StoreSubmissionService {
|
|||||||
} catch (Exception logEx) {
|
} catch (Exception logEx) {
|
||||||
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), storeType, logEx.getMessage());
|
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), storeType, logEx.getMessage());
|
||||||
}
|
}
|
||||||
|
boolean seqAlreadyLive = false;
|
||||||
try {
|
try {
|
||||||
storeService.updateStoreReview(versionId, storeType,
|
AppStoreConfigEntity liveCfg = configRepo
|
||||||
AppVersionEntity.StoreReviewState.REJECTED,
|
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
|
||||||
message.length() > 500 ? message.substring(0, 500) : message);
|
.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) {
|
} 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);
|
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
|
* Poll vendor APIs every 10 minutes for versions with UNDER_REVIEW or REJECTED stores.
|
||||||
* 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.
|
* 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)
|
@Scheduled(fixedDelay = 10 * 60_000)
|
||||||
public void pollStoreReviewStatus() {
|
public void pollStoreReviewStatus() {
|
||||||
List<AppVersionEntity> candidates = versionRepo.findAllWithUnderReviewStores();
|
List<AppVersionEntity> candidates = versionRepo.findAllWithUnderReviewOrRejectedStores();
|
||||||
if (candidates.isEmpty()) return;
|
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) {
|
for (AppVersionEntity v : candidates) {
|
||||||
Map<String, Object> reviewMap;
|
Map<String, Object> reviewMap;
|
||||||
try {
|
try {
|
||||||
@ -375,7 +395,9 @@ public class StoreSubmissionService {
|
|||||||
Map<String, Object> info = entry.getValue() instanceof Map<?, ?> m ? (Map<String, Object>) m : null;
|
Map<String, Object> info = entry.getValue() instanceof Map<?, ?> m ? (Map<String, Object>) m : null;
|
||||||
if (info == null) continue;
|
if (info == null) continue;
|
||||||
String state = info.get("state") instanceof String s ? s : "";
|
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;
|
AppStoreConfigEntity cfg;
|
||||||
try {
|
try {
|
||||||
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(),
|
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(),
|
||||||
@ -387,14 +409,27 @@ public class StoreSubmissionService {
|
|||||||
try {
|
try {
|
||||||
Map<String, String> creds = parseConfig(cfg.getConfigJson());
|
Map<String, String> creds = parseConfig(cfg.getConfigJson());
|
||||||
AppVersionEntity.StoreReviewState polled = pollStoreSingleReviewState(storeType, v, creds);
|
AppVersionEntity.StoreReviewState polled = pollStoreSingleReviewState(storeType, v, creds);
|
||||||
if (polled != null && polled != AppVersionEntity.StoreReviewState.UNDER_REVIEW) {
|
if (polled == null) {
|
||||||
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);
|
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 {
|
} 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) {
|
} catch (Exception e) {
|
||||||
log.warn("Store review poll error for {}/{}: {}", v.getId(), storeType, e.getMessage());
|
log.warn("Store review poll error for {}/{}: {}", v.getId(), storeType, e.getMessage());
|
||||||
@ -636,12 +671,30 @@ public class StoreSubmissionService {
|
|||||||
} catch (Exception logEx) {
|
} catch (Exception logEx) {
|
||||||
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage());
|
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage());
|
||||||
}
|
}
|
||||||
|
boolean alreadyLive = false;
|
||||||
try {
|
try {
|
||||||
storeService.updateStoreReview(versionId, plan.storeType,
|
AppStoreConfigEntity liveCfg = configRepo
|
||||||
AppVersionEntity.StoreReviewState.REJECTED,
|
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(plan.storeType))
|
||||||
message.length() > 500 ? message.substring(0, 500) : message);
|
.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) {
|
} 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);
|
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户