feat(private-deploy): 支持 MySQL/Redis 外部连接和托管模式部署

- 添加 external 和 managed 两种数据库/缓存模式支持
- 实现 MySQL/Redis 托管安装脚本和配置向导
- 支持客户自备连接或部署脚本新建基础设施
- 更新部署文档说明不同模式的配置和验证要求
- 添加应用版本防重复上传和删除功能
- 实现应用商店预提交检查和发布计划功能
这个提交包含在:
XuqmGroup 2026-05-18 18:37:10 +08:00
父节点 e309a41ed0
当前提交 87edb316a5
共有 8 个文件被更改,包括 852 次插入192 次删除

查看文件

@ -134,6 +134,22 @@ public class AppStoreController {
return ResponseEntity.ok(ApiResponse.success(v));
}
// Preflight check before submission
/**
* Query remote state of all target stores before submitting.
* Returns per-store review state, online version, and whether submission is allowed.
*/
@PostMapping("/app/{versionId}/preflight-submit")
public ResponseEntity<ApiResponse<com.xuqm.update.model.PreflightSubmitResult>> preflightSubmit(
@PathVariable String versionId) {
AppVersionEntity v = submissionService.getVersionForPreflight(versionId);
List<com.xuqm.update.model.StoreRemoteState> states = submissionService.preflightStoreSubmission(versionId);
return ResponseEntity.ok(ApiResponse.success(
com.xuqm.update.model.PreflightSubmitResult.of(
versionId, v.getVersionName(), v.getVersionCode(), states)));
}
// Review status update (from store webhook or manual)
/**

查看文件

@ -142,6 +142,28 @@ public class AppVersionController {
if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases");
}
// Duplicate version check
if (hasText(resolvedPackageName)) {
java.util.Optional<AppVersionEntity> existing = versionRepository
.findByAppKeyAndPlatformAndPackageNameAndVersionCode(
appKey, platform, resolvedPackageName, resolvedVersionCode);
if (existing.isPresent()) {
throw new IllegalArgumentException(
"版本已存在,不能重复上传:" + resolvedVersionName + " · " + resolvedVersionCode);
}
// Check if same versionCode exists with any store already live
java.util.List<AppVersionEntity> sameCodeVersions = versionRepository
.findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus(
appKey, platform, resolvedPackageName, resolvedVersionCode,
AppVersionEntity.PublishStatus.PUBLISHED);
if (!sameCodeVersions.isEmpty() && inspected != null) {
// If a published version with same code exists, compare APK md5
// This is a conservative check; we can't easily compare md5 here without downloading,
// so we just block if same versionCode was ever published
throw new IllegalArgumentException(
"应用商店已上线相同版本号,不允许重复上传:" + resolvedVersionName + " · " + resolvedVersionCode);
}
}
AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
@ -284,6 +306,51 @@ public class AppVersionController {
return ResponseEntity.ok(ApiResponse.success(saved));
}
@DeleteMapping("/app/{id}")
public ResponseEntity<ApiResponse<Void>> deleteVersion(@PathVariable String id) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
if (entity.getPublishStatus() != AppVersionEntity.PublishStatus.DRAFT) {
throw new IllegalArgumentException("已发布或已下架的版本不允许删除,请先下架");
}
// Check if any store is in active review
if (hasActiveStoreReview(entity)) {
throw new IllegalArgumentException("当前版本有厂商正在审核中,不允许删除");
}
versionRepository.delete(entity);
operationLogService.record(
entity.getAppKey(),
"APP_VERSION",
id,
"DELETE",
null,
Map.of(
"versionName", entity.getVersionName(),
"versionCode", entity.getVersionCode()
));
return ResponseEntity.ok(ApiResponse.success(null));
}
private boolean hasActiveStoreReview(AppVersionEntity entity) {
if (entity.getStoreReviewStatus() == null || entity.getStoreReviewStatus().isBlank()) {
return false;
}
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
@SuppressWarnings("unchecked")
java.util.Map<String, java.util.Map<String, Object>> reviewMap =
mapper.readValue(entity.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {});
for (java.util.Map<String, Object> info : reviewMap.values()) {
String state = info.get("state") instanceof String s ? s : "";
if ("SUBMITTING".equals(state) || "UNDER_REVIEW".equals(state)) {
return true;
}
}
} catch (Exception e) {
return false;
}
return false;
}
@PostMapping("/app/{id}/gray")
public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
@PathVariable String id,

查看文件

@ -109,6 +109,19 @@ public class AppVersionEntity {
@Column(length = 512)
private String grayCallbackUrl;
/**
* Pending publish plan to apply after all stores approve the current submission.
* IMMEDIATE or SCHEDULED. Null means no pending plan.
*/
@Column(length = 16)
private String pendingStorePublishType;
/** When pendingStorePublishType is SCHEDULED, the scheduled publish time. */
private LocalDateTime pendingStorePublishScheduledAt;
/** Last time the pending publish plan was updated. */
private LocalDateTime pendingStorePublishUpdatedAt;
/** App package name / bundle identifier, e.g. com.example.myapp */
@Column(length = 256)
private String packageName;
@ -190,4 +203,13 @@ public class AppVersionEntity {
public String getGrayCallbackUrl() { return grayCallbackUrl; }
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; }
public String getPendingStorePublishType() { return pendingStorePublishType; }
public void setPendingStorePublishType(String pendingStorePublishType) { this.pendingStorePublishType = pendingStorePublishType; }
public LocalDateTime getPendingStorePublishScheduledAt() { return pendingStorePublishScheduledAt; }
public void setPendingStorePublishScheduledAt(LocalDateTime pendingStorePublishScheduledAt) { this.pendingStorePublishScheduledAt = pendingStorePublishScheduledAt; }
public LocalDateTime getPendingStorePublishUpdatedAt() { return pendingStorePublishUpdatedAt; }
public void setPendingStorePublishUpdatedAt(LocalDateTime pendingStorePublishUpdatedAt) { this.pendingStorePublishUpdatedAt = pendingStorePublishUpdatedAt; }
}

查看文件

@ -0,0 +1,35 @@
package com.xuqm.update.model;
import java.util.List;
/**
* Result of preflight check before submitting to app stores.
*/
public class PreflightSubmitResult {
private String versionId;
private String versionName;
private int versionCode;
private List<StoreRemoteState> stores;
public static PreflightSubmitResult of(String versionId, String versionName, int versionCode, List<StoreRemoteState> stores) {
PreflightSubmitResult r = new PreflightSubmitResult();
r.versionId = versionId;
r.versionName = versionName;
r.versionCode = versionCode;
r.stores = stores;
return r;
}
public String getVersionId() { return versionId; }
public void setVersionId(String versionId) { this.versionId = versionId; }
public String getVersionName() { return versionName; }
public void setVersionName(String versionName) { this.versionName = versionName; }
public int getVersionCode() { return versionCode; }
public void setVersionCode(int versionCode) { this.versionCode = versionCode; }
public List<StoreRemoteState> getStores() { return stores; }
public void setStores(List<StoreRemoteState> stores) { this.stores = stores; }
}

查看文件

@ -0,0 +1,113 @@
package com.xuqm.update.model;
import com.xuqm.update.entity.AppStoreConfigEntity;
/**
* Unified remote state returned by all vendor app store APIs.
* Normalises Huawei, Xiaomi, OPPO, vivo and Honor responses into one model.
*/
public class StoreRemoteState {
public enum ReviewState {
ONLINE("已上线"),
UNDER_REVIEW("审核中"),
UNDER_REVIEW_XIAOMI("审核中(版本号可能不准确)"),
REJECTED("已拒绝"),
NOT_FOUND("未找到"),
UNKNOWN("未知状态"),
QUERY_FAILED("查询失败");
private final String label;
ReviewState(String label) { this.label = label; }
public String getLabel() { return label; }
}
private AppStoreConfigEntity.StoreType storeType;
private ReviewState reviewState;
private String onlineVersionName;
private String onlineVersionCode;
private String reviewVersionName;
private String reviewVersionCode;
private boolean currentSubmissionLive;
private boolean nonCurrentRelease;
private boolean canSubmit;
private String blockReason;
private String rawResponse;
private String rawError;
public static StoreRemoteState ok(AppStoreConfigEntity.StoreType storeType,
ReviewState reviewState,
String onlineVersionName,
String onlineVersionCode,
String reviewVersionName,
String reviewVersionCode,
boolean currentSubmissionLive,
boolean nonCurrentRelease,
boolean canSubmit,
String blockReason,
String rawResponse) {
StoreRemoteState s = new StoreRemoteState();
s.storeType = storeType;
s.reviewState = reviewState;
s.onlineVersionName = onlineVersionName == null ? "" : onlineVersionName;
s.onlineVersionCode = onlineVersionCode == null ? "" : onlineVersionCode;
s.reviewVersionName = reviewVersionName == null ? "" : reviewVersionName;
s.reviewVersionCode = reviewVersionCode == null ? "" : reviewVersionCode;
s.currentSubmissionLive = currentSubmissionLive;
s.nonCurrentRelease = nonCurrentRelease;
s.canSubmit = canSubmit;
s.blockReason = blockReason == null ? "" : blockReason;
s.rawResponse = rawResponse == null ? "" : rawResponse;
return s;
}
public static StoreRemoteState failed(AppStoreConfigEntity.StoreType storeType,
String rawError,
String blockReason) {
StoreRemoteState s = new StoreRemoteState();
s.storeType = storeType;
s.reviewState = ReviewState.QUERY_FAILED;
s.rawError = rawError == null ? "" : rawError;
s.blockReason = blockReason == null ? "查询失败,不能确认状态" : blockReason;
s.canSubmit = false;
return s;
}
public AppStoreConfigEntity.StoreType getStoreType() { return storeType; }
public void setStoreType(AppStoreConfigEntity.StoreType storeType) { this.storeType = storeType; }
public ReviewState getReviewState() { return reviewState; }
public void setReviewState(ReviewState reviewState) { this.reviewState = reviewState; }
public String getOnlineVersionName() { return onlineVersionName; }
public void setOnlineVersionName(String onlineVersionName) { this.onlineVersionName = onlineVersionName; }
public String getOnlineVersionCode() { return onlineVersionCode; }
public void setOnlineVersionCode(String onlineVersionCode) { this.onlineVersionCode = onlineVersionCode; }
public String getReviewVersionName() { return reviewVersionName; }
public void setReviewVersionName(String reviewVersionName) { this.reviewVersionName = reviewVersionName; }
public String getReviewVersionCode() { return reviewVersionCode; }
public void setReviewVersionCode(String reviewVersionCode) { this.reviewVersionCode = reviewVersionCode; }
public boolean isCurrentSubmissionLive() { return currentSubmissionLive; }
public void setCurrentSubmissionLive(boolean currentSubmissionLive) { this.currentSubmissionLive = currentSubmissionLive; }
public boolean isNonCurrentRelease() { return nonCurrentRelease; }
public void setNonCurrentRelease(boolean nonCurrentRelease) { this.nonCurrentRelease = nonCurrentRelease; }
public boolean isCanSubmit() { return canSubmit; }
public void setCanSubmit(boolean canSubmit) { this.canSubmit = canSubmit; }
public String getBlockReason() { return blockReason; }
public void setBlockReason(String blockReason) { this.blockReason = blockReason; }
public String getRawResponse() { return rawResponse; }
public void setRawResponse(String rawResponse) { this.rawResponse = rawResponse; }
public String getRawError() { return rawError; }
public void setRawError(String rawError) { this.rawError = rawError; }
}

查看文件

@ -42,4 +42,11 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
List<AppVersionEntity> findByAppKeyAndPlatformAndVersionCodeAndIdNot(
String appKey, AppVersionEntity.Platform platform, int versionCode, String id);
Optional<AppVersionEntity> findByAppKeyAndPlatformAndPackageNameAndVersionCode(
String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode);
List<AppVersionEntity> findByAppKeyAndPlatformAndPackageNameAndVersionCodeAndPublishStatus(
String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode,
AppVersionEntity.PublishStatus publishStatus);
}

查看文件

@ -313,18 +313,7 @@ public class AppStoreService {
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) {
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
log.info("Auto-published version {} after all stores approved", versionId);
operationLogService.record(
v.getAppKey(),
"APP_VERSION",
v.getId(),
"AUTO_PUBLISH",
reason,
Map.of(
"storeType", storeType,
"publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name()
));
applyPendingPublishPlan(v, storeType, reason);
}
AppVersionEntity saved = versionRepo.save(v);
@ -365,6 +354,14 @@ public class AppStoreService {
String storeType,
boolean preExisting,
String reason) throws Exception {
return updateStoreReviewLive(versionId, storeType, preExisting, reason, null);
}
public AppVersionEntity updateStoreReviewLive(String versionId,
String storeType,
boolean preExisting,
String reason,
Map<String, Object> extraPayload) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
@ -382,6 +379,9 @@ public class AppStoreService {
if (preExisting) {
extra.put("preExisting", true);
}
if (extraPayload != null) {
extra.putAll(extraPayload);
}
reviewMap.put(storeType, reviewPayload(
AppVersionEntity.StoreReviewState.APPROVED.name(),
reason == null ? "版本已在应用商店上线" : reason,
@ -393,10 +393,7 @@ public class AppStoreService {
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()));
applyPendingPublishPlan(v, storeType, reason);
}
AppVersionEntity saved = versionRepo.save(v);
@ -641,8 +638,57 @@ public class AppStoreService {
private boolean allApproved(AppVersionEntity v, Map<String, Object> reviewMap) throws Exception {
if (v.getStoreSubmitTargets() == null) return false;
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
return targets.stream().allMatch(t ->
AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(reviewMap.get(t))));
return targets.stream().allMatch(t -> {
Object entry = reviewMap.get(t);
if (!AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(entry))) {
return false;
}
if (entry instanceof Map<?, ?> map) {
Object currentSubmissionLive = map.get("currentSubmissionLive");
return !(currentSubmissionLive instanceof Boolean b) || b;
}
return true;
});
}
/**
* Apply the pending publish plan when all targeted stores are approved.
* If pending plan is IMMEDIATE -> publish now.
* If pending plan is SCHEDULED -> set scheduledPublishAt, keep DRAFT.
*/
private void applyPendingPublishPlan(AppVersionEntity v, String triggerStoreType, String reason) {
String pendingType = v.getPendingStorePublishType();
if ("IMMEDIATE".equalsIgnoreCase(pendingType)) {
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
v.setPendingStorePublishType(null);
v.setPendingStorePublishScheduledAt(null);
log.info("Auto-published version {} after all stores approved (pending plan=IMMEDIATE)", v.getId());
operationLogService.record(
v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_PUBLISH",
reason, Map.of("storeType", triggerStoreType,
"publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name(),
"pendingPlan", "IMMEDIATE"));
} else if ("SCHEDULED".equalsIgnoreCase(pendingType) && v.getPendingStorePublishScheduledAt() != null) {
v.setScheduledPublishAt(v.getPendingStorePublishScheduledAt());
v.setPendingStorePublishType(null);
v.setPendingStorePublishScheduledAt(null);
log.info("Auto-scheduled version {} after all stores approved (pending plan=SCHEDULED at {})",
v.getId(), v.getScheduledPublishAt());
operationLogService.record(
v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_SCHEDULE",
reason, Map.of("storeType", triggerStoreType,
"publishStatus", AppVersionEntity.PublishStatus.DRAFT.name(),
"scheduledPublishAt", v.getScheduledPublishAt().toString(),
"pendingPlan", "SCHEDULED"));
} else {
// Default: immediate publish when no pending plan exists (backward compatible)
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
log.info("Auto-published version {} after all stores approved (no pending plan)", v.getId());
operationLogService.record(
v.getAppKey(), "APP_VERSION", v.getId(), "AUTO_PUBLISH",
reason, Map.of("storeType", triggerStoreType,
"publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name()));
}
}
private String resolveWebhookSecret(String appKey) {

查看文件

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.model.StoreRemoteState;
import com.xuqm.update.repository.AppStoreConfigRepository;
import com.xuqm.update.repository.AppVersionRepository;
import org.slf4j.Logger;
@ -26,8 +27,14 @@ import java.io.File;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@ -62,6 +69,10 @@ public class StoreSubmissionService {
private static final String HONOR_IAM = "https://iam.developer.honor.com";
private final RestTemplate rest = buildRestTemplate();
private final HttpClient miHttp = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.version(HttpClient.Version.HTTP_1_1)
.build();
private final AppVersionRepository versionRepo;
private final AppStoreConfigRepository configRepo;
private final AppStoreService storeService;
@ -72,6 +83,19 @@ public class StoreSubmissionService {
@Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir;
// vivo query rate-limit cache: key = appKey + ":" + packageName
private final java.util.concurrent.ConcurrentMap<String, VivoCacheEntry> vivoQueryCache = new java.util.concurrent.ConcurrentHashMap<>();
private static final long VIVO_CACHE_TTL_MS = 30_000;
private static final class VivoCacheEntry {
final StoreRemoteState state;
final long timestamp;
VivoCacheEntry(StoreRemoteState state, long timestamp) {
this.state = state;
this.timestamp = timestamp;
}
}
public StoreSubmissionService(AppVersionRepository versionRepo,
AppStoreConfigRepository configRepo,
AppStoreService storeService,
@ -352,6 +376,276 @@ public class StoreSubmissionService {
}
}
public AppVersionEntity getVersionForPreflight(String versionId) {
return versionRepo.findById(versionId)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId));
}
/**
* Preflight check before submitting to app stores.
* Queries each target store's remote state and returns whether submission is allowed.
*/
public List<StoreRemoteState> preflightStoreSubmission(String versionId) {
AppVersionEntity v = versionRepo.findById(versionId).orElse(null);
if (v == null) {
throw new IllegalArgumentException("Version not found: " + versionId);
}
List<String> targets = parseTargets(v.getStoreSubmitTargets());
if (targets.isEmpty()) {
targets = storeService.resolveDefaultStoreTargets(v.getAppKey());
}
List<StoreRemoteState> results = new ArrayList<>();
for (String storeType : targets) {
AppStoreConfigEntity cfg;
try {
cfg = configRepo.findByAppKeyAndStoreType(v.getAppKey(),
AppStoreConfigEntity.StoreType.valueOf(storeType)).orElse(null);
} catch (Exception e) {
results.add(StoreRemoteState.failed(
AppStoreConfigEntity.StoreType.valueOf(storeType),
"Store config invalid: " + e.getMessage(),
"厂商配置无效"));
continue;
}
if (cfg == null || !cfg.isEnabled()) {
results.add(StoreRemoteState.failed(
AppStoreConfigEntity.StoreType.valueOf(storeType),
"Store config not found or disabled",
"厂商未配置或已禁用"));
continue;
}
try {
Map<String, String> creds = parseConfig(cfg.getConfigJson());
StoreRemoteState state = queryRemoteState(storeType, v, creds);
// Apply preflight rules
String submittedCode = String.valueOf(v.getVersionCode());
String submittedName = v.getVersionName();
if (state.isCurrentSubmissionLive() ||
(submittedCode.equals(state.getOnlineVersionCode()) && !submittedCode.isBlank())) {
state.setCanSubmit(false);
state.setBlockReason("当前版本已在应用商店上线,禁止重复提审");
} else if (state.getReviewState() == StoreRemoteState.ReviewState.UNDER_REVIEW &&
submittedCode.equals(state.getReviewVersionCode()) && !submittedCode.isBlank()) {
state.setCanSubmit(false);
state.setBlockReason("当前版本正在审核中,禁止重复提审");
} else if (state.getReviewState() == StoreRemoteState.ReviewState.QUERY_FAILED) {
state.setCanSubmit(false);
state.setBlockReason("查询失败,不能确认状态");
} else if (!state.getOnlineVersionCode().isBlank() && !submittedCode.equals(state.getOnlineVersionCode())) {
state.setNonCurrentRelease(true);
state.setCanSubmit(true);
} else {
state.setCanSubmit(true);
}
results.add(state);
} catch (Exception e) {
log.warn("Preflight query failed for {}/{}: {}", v.getId(), storeType, e.getMessage());
results.add(StoreRemoteState.failed(
AppStoreConfigEntity.StoreType.valueOf(storeType),
describeException(e),
"查询失败,不能确认状态"));
}
}
return results;
}
/**
* Query remote state for a single store. Returns a unified StoreRemoteState.
*/
private StoreRemoteState queryRemoteState(String storeType, AppVersionEntity v, Map<String, String> creds) throws Exception {
return switch (storeType) {
case "HUAWEI" -> queryHuaweiRemoteState(v, creds);
case "MI" -> queryMiRemoteState(v, creds);
case "OPPO" -> queryOppoRemoteState(v, creds);
case "VIVO" -> queryVivoRemoteState(v, creds);
case "HONOR" -> queryHonorRemoteState(v, creds);
default -> throw new IllegalArgumentException("Unknown store: " + storeType);
};
}
private StoreRemoteState queryHuaweiRemoteState(AppVersionEntity v, Map<String, String> creds) throws Exception {
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);
HttpHeaders headers = huaweiHeaders(clientId, token);
ResponseEntity<Map> resp = rest.exchange(
HUAWEI_API + "/api/publish/v2/app-info?appId=" + hwAppId,
HttpMethod.GET, new HttpEntity<>(headers), Map.class);
Map<String, Object> body = requireBodyMap(resp.getBody(), "HUAWEI app-info query");
Map<String, Object> appInfoMap = body.get("appInfo") instanceof Map<?, ?> m
? (Map<String, Object>) m : body;
String onShelfCode = String.valueOf(appInfoMap.getOrDefault("onShelfVersionCode", ""));
String onShelfName = firstNonBlank(
stringValue(appInfoMap.get("onShelfVersionName")),
stringValue(appInfoMap.get("versionName")),
stringValue(appInfoMap.get("appVersionName")));
String versionCode = String.valueOf(appInfoMap.getOrDefault("versionCode", ""));
String versionName = stringValue(appInfoMap.get("versionName"));
String submittedCode = String.valueOf(v.getVersionCode());
Object releaseStateObj = appInfoMap.get("releaseState");
int releaseState = releaseStateObj != null ? Integer.parseInt(String.valueOf(releaseStateObj)) : -1;
boolean isLive = !submittedCode.isBlank() && submittedCode.equals(onShelfCode);
StoreRemoteState.ReviewState reviewState;
if (isLive) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else if (releaseState == 7) {
reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (releaseState == 4 || releaseState == 5) {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW;
} else if (releaseState == 0) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else {
reviewState = StoreRemoteState.ReviewState.UNKNOWN;
}
return StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.HUAWEI,
reviewState,
onShelfName, onShelfCode,
versionName, versionCode,
isLive, !isLive && !onShelfCode.isBlank(),
true, "", sanitizeJson(resp.getBody()));
}
private StoreRemoteState queryMiRemoteState(AppVersionEntity v, Map<String, String> creds) throws Exception {
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");
String onlineVersionCode = pkg.path("versionCode").asText("");
String onlineVersionName = pkg.path("versionName").asText("");
int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1));
boolean updateVersion = root.path("updateVersion").asBoolean(false);
String submittedCode = String.valueOf(v.getVersionCode());
boolean isLive = updateVersion || submittedCode.equals(onlineVersionCode);
StoreRemoteState.ReviewState reviewState;
if (appStatus == 5) {
reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (updateVersion) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI;
}
return StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.MI,
reviewState,
onlineVersionName, onlineVersionCode,
"", "",
isLive, !isLive && !onlineVersionCode.isBlank(),
true, "", sanitizeJson(root));
}
private StoreRemoteState queryOppoRemoteState(AppVersionEntity v, Map<String, String> creds) throws Exception {
String clientId = require(creds, "clientId", "OPPO");
String clientSecret = require(creds, "clientSecret", "OPPO");
String token = oppoGetToken(clientId, clientSecret);
JsonNode appData = oppoGetAppInfo(token, requirePackageName(v), clientId, clientSecret);
String onlineVersionCode = appData.path("versionCode").asText("");
String onlineVersionName = appData.path("versionName").asText("");
int auditInt = appData.path("audit_status").asInt(appData.path("auditStatus").asInt(-1));
String submittedCode = String.valueOf(v.getVersionCode());
boolean isLive = auditInt == 111 || List.of("2", "3", "4").contains(String.valueOf(auditInt));
StoreRemoteState.ReviewState reviewState;
if (auditInt == 444 || "5".equals(String.valueOf(auditInt))) {
reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (isLive) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW;
}
return StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.OPPO,
reviewState,
onlineVersionName, onlineVersionCode,
"", "",
isLive && submittedCode.equals(onlineVersionCode),
isLive && !submittedCode.equals(onlineVersionCode),
true, "", sanitizeJson(appData));
}
private StoreRemoteState queryVivoRemoteState(AppVersionEntity v, Map<String, String> creds) throws Exception {
String accessKey = require(creds, "accessKey", "VIVO");
String accessSecret = require(creds, "accessSecret", "VIVO");
String pkg = requirePackageName(v);
String cacheKey = v.getAppKey() + ":" + pkg;
VivoCacheEntry cached = vivoQueryCache.get(cacheKey);
if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) {
log.debug("VIVO query cache hit for {}", cacheKey);
return cached.state;
}
String url = vivoRequestUrl(accessKey, accessSecret, "app.query.details", Map.of("packageName", pkg));
ResponseEntity<String> resp = rest.getForEntity(url, String.class);
JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody()));
JsonNode data = root.path("data");
int status = data.path("status").asInt(-1);
String onlineVersionCode = data.path("versionCode").asText("");
String onlineVersionName = data.path("versionName").asText("");
String submittedCode = String.valueOf(v.getVersionCode());
boolean isLive = status == 3;
StoreRemoteState.ReviewState reviewState;
if (status == 4) {
reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (status == 3) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else if (status == 2) {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW;
} else if (status == 1) {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW;
} else {
reviewState = StoreRemoteState.ReviewState.UNKNOWN;
}
StoreRemoteState state = StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.VIVO,
reviewState,
onlineVersionName, onlineVersionCode,
"", "",
isLive && submittedCode.equals(onlineVersionCode),
isLive && !submittedCode.equals(onlineVersionCode),
true, "", sanitizeJson(root));
vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis()));
return state;
}
private StoreRemoteState queryHonorRemoteState(AppVersionEntity v, Map<String, String> creds) throws Exception {
String clientId = require(creds, "clientId", "HONOR");
String clientSecret = require(creds, "clientSecret", "HONOR");
String token = honorGetToken(clientId, clientSecret);
int appId = honorGetAppId(token, requirePackageName(v));
HttpHeaders headers = honorHeaders(token);
String url = HONOR_API + "/openapi/v1/publish/get-app-current-release?appId=" + appId;
ResponseEntity<Map> resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class);
Map<String, Object> body = requireBodyMap(resp.getBody(), "HONOR current-release query");
assertHonorSuccess(body, "current-release query");
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;
String onlineVersionCode = String.valueOf(data.getOrDefault("versionCode", ""));
String onlineVersionName = stringValue(data.get("versionName"));
String submittedCode = String.valueOf(v.getVersionCode());
boolean isLive = status == 1;
StoreRemoteState.ReviewState reviewState;
if (status == 2) {
reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (status == 1) {
reviewState = StoreRemoteState.ReviewState.ONLINE;
} else if (status == 0) {
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW;
} else {
reviewState = StoreRemoteState.ReviewState.UNKNOWN;
}
return StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.HONOR,
reviewState,
onlineVersionName, onlineVersionCode,
"", "",
isLive && submittedCode.equals(onlineVersionCode),
isLive && !submittedCode.equals(onlineVersionCode),
true, "", sanitizeJson(body));
}
/**
* Poll vendor APIs every 10 minutes for versions with UNDER_REVIEW or REJECTED stores.
*
@ -390,30 +684,28 @@ public class StoreSubmissionService {
if (cfg == null || !cfg.isEnabled()) continue;
try {
Map<String, String> creds = parseConfig(cfg.getConfigJson());
AppVersionEntity.StoreReviewState polled = pollStoreSingleReviewState(storeType, v, creds);
StoreRemoteState polled = queryRemoteState(storeType, v, creds);
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, "厂商审核状态轮询检测:版本已上线");
AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState());
if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) {
log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, mappedState);
if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) {
storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(),
buildLiveReason(polled), buildExtra(polled));
} else {
storeService.updateStoreReview(v.getId(), storeType, polled, "厂商审核状态轮询检测");
storeService.updateStoreReview(v.getId(), storeType, mappedState, "厂商审核状态轮询检测");
}
} else {
log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType);
}
} else {
// REJECTED store check if the specific submitted versionCode is now live.
// Only HUAWEI has a reliable version-specific check (compares onShelfVersionCode).
// Other store polls return the live status for ANY version, so we can't trust
// their APPROVED result here without risking false positives (e.g. an older
// version is live but we'd incorrectly mark the new submission as pre-existing).
if (polled == AppVersionEntity.StoreReviewState.APPROVED && "HUAWEI".equals(storeType)) {
log.info("Store review poll: {}/{} was REJECTED but Huawei confirms this versionCode is on-shelf — marking pre-existing", v.getId(), storeType);
storeService.updateStoreReviewLive(v.getId(), storeType, true,
"版本已在应用商店上线(提交失败但检测到相同版本已上线,可能为直接上传)");
if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) {
log.info("Store review poll: {}/{} was REJECTED but store has live version currentSubmissionLive={} onlineVersionName={} onlineVersionCode={}",
v.getId(), storeType, polled.isCurrentSubmissionLive(), polled.getOnlineVersionName(), polled.getOnlineVersionCode());
storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(),
buildLiveReason(polled), buildExtra(polled));
}
// otherwise leave REJECTED as-is
}
@ -424,144 +716,41 @@ public class StoreSubmissionService {
}
}
@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={} data={} for version={}", status, root.path("data"), 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);
// Log full root response so we can verify exact field names from production
log.info("MI poll full response for version={}: {}", v.getId(), root);
JsonNode pkg = root.path("packageInfo");
// Xiaomi /devupload/dev/query returns status of the currently-published app,
// NOT the pending submission. appStatus=3 means the old version is online
// it does NOT mean the newly-submitted version was accepted.
//
// Reliable fields:
// root.updateVersion (bool): true = new version update available to users
// (i.e. the newly submitted version is APPROVED/published)
// false = users cannot update (new version still under review OR rejected)
// packageInfo.appStatus: 5 = this version was explicitly rejected/驳回
// packageInfo.synchroResult: 1=上线成功, 0=拒绝/失败
int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1));
// updateVersion=false means the submitted version is not yet live (either under review or rejected)
// We need a DIFFERENT signal to distinguish rejected from still-under-review
boolean updateVersion = root.path("updateVersion").asBoolean(false);
log.info("MI poll appStatus={} updateVersion={} for version={}", appStatus, updateVersion, v.getId());
// appStatus=5 is an explicit rejection signal
if (appStatus == 5) {
yield AppVersionEntity.StoreReviewState.REJECTED;
}
// updateVersion=true means the submitted version is now live/approved
if (updateVersion) {
yield AppVersionEntity.StoreReviewState.APPROVED;
}
// Fall back: synchroResult distinguishes rejected(0) from still-pending(-1)
int synchroResult = pkg.path("synchroResult").asInt(-1);
log.info("MI poll synchroResult={} for version={}", synchroResult, v.getId());
yield synchroResult == 0 ? AppVersionEntity.StoreReviewState.REJECTED
: AppVersionEntity.StoreReviewState.UNDER_REVIEW;
}
default -> null; // APP_STORE, GOOGLE_PLAY: no vendor query API
private AppVersionEntity.StoreReviewState mapToStoreReviewState(StoreRemoteState.ReviewState reviewState) {
return switch (reviewState) {
case ONLINE -> AppVersionEntity.StoreReviewState.APPROVED;
case UNDER_REVIEW, UNDER_REVIEW_XIAOMI -> AppVersionEntity.StoreReviewState.UNDER_REVIEW;
case REJECTED -> AppVersionEntity.StoreReviewState.REJECTED;
default -> AppVersionEntity.StoreReviewState.UNDER_REVIEW;
};
}
private String buildLiveReason(StoreRemoteState result) {
if (result.isCurrentSubmissionLive()) {
return "厂商审核状态轮询检测:本次提交版本已上线";
}
String version = firstNonBlank(result.getOnlineVersionName(), result.getOnlineVersionCode());
if (!version.isBlank()) {
return "应用商店已有线上版本 " + version + ",非本次发布";
}
return "应用商店已有线上版本,非本次发布";
}
private Map<String, Object> buildExtra(StoreRemoteState result) {
Map<String, Object> extra = new LinkedHashMap<>();
extra.put("currentSubmissionLive", result.isCurrentSubmissionLive());
if (!result.getOnlineVersionName().isBlank()) {
extra.put("liveVersionName", result.getOnlineVersionName());
}
if (!result.getOnlineVersionCode().isBlank()) {
extra.put("liveVersionCode", result.getOnlineVersionCode());
}
if (result.isNonCurrentRelease()) {
extra.put("nonCurrentRelease", true);
}
return extra;
}
/**
* On startup, mark any stores stuck in SUBMITTING as FAILED.
* A SUBMITTING state with no corresponding active thread means the service was
@ -1321,17 +1510,56 @@ public class StoreSubmissionService {
* Change when the approved version goes live.
* publishType: "IMMEDIATE" or "SCHEDULED"
* scheduledAt: required when publishType == "SCHEDULED"
*
* Three-state logic:
* 1. PUBLISHED -> reject (already live, no schedule change needed)
* 2. DRAFT + all stores APPROVED -> modify scheduledPublishAt directly
* 3. UNDER_REVIEW -> save to pending fields, apply after all stores approve
*/
public AppVersionEntity updatePublishSchedule(String versionId, String publishType,
java.time.LocalDateTime scheduledAt) {
AppVersionEntity v = versionRepo.findById(versionId)
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId));
// 1. Already published -> reject
if (v.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) {
throw new IllegalArgumentException("版本已上线,无需修改发布计划");
}
// Check if all targeted stores are approved
boolean allApproved = isAllStoresApproved(v);
boolean anyUnderReview = isAnyStoreUnderReview(v);
if ("IMMEDIATE".equalsIgnoreCase(publishType)) {
v.setScheduledPublishAt(null);
if (anyUnderReview) {
// 3. Under review -> save as pending plan
v.setPendingStorePublishType("IMMEDIATE");
v.setPendingStorePublishScheduledAt(null);
v.setPendingStorePublishUpdatedAt(LocalDateTime.now());
} else if (allApproved) {
// 2. All approved but not published -> publish immediately
v.setScheduledPublishAt(null);
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
} else {
// No active review -> just clear schedule
v.setScheduledPublishAt(null);
}
} else if ("SCHEDULED".equalsIgnoreCase(publishType)) {
if (scheduledAt == null) throw new IllegalArgumentException("scheduledAt required for SCHEDULED publish");
v.setScheduledPublishAt(scheduledAt);
if (anyUnderReview) {
// 3. Under review -> save as pending plan
v.setPendingStorePublishType("SCHEDULED");
v.setPendingStorePublishScheduledAt(scheduledAt);
v.setPendingStorePublishUpdatedAt(LocalDateTime.now());
} else if (allApproved) {
// 2. All approved but not published -> set schedule directly
v.setScheduledPublishAt(scheduledAt);
} else {
// No active review -> just set schedule
v.setScheduledPublishAt(scheduledAt);
}
}
// Try to call HUAWEI API to update release plan if token available
try {
AppStoreConfigEntity cfg = configRepo
@ -1347,6 +1575,45 @@ public class StoreSubmissionService {
return versionRepo.save(v);
}
private boolean isAllStoresApproved(AppVersionEntity v) {
if (v.getStoreSubmitTargets() == null || v.getStoreReviewStatus() == null) return false;
try {
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<List<String>>() {});
@SuppressWarnings("unchecked")
Map<String, Map<String, Object>> reviewMap =
mapper.readValue(v.getStoreReviewStatus(), new TypeReference<Map<String, Map<String, Object>>>() {});
for (String t : targets) {
Map<String, Object> info = reviewMap.get(t);
if (info == null) return false;
String state = info.get("state") instanceof String s ? s : "";
if (!"APPROVED".equals(state)) return false;
Object currentSubmissionLive = info.get("currentSubmissionLive");
if (currentSubmissionLive instanceof Boolean b && !b) return false;
}
return !targets.isEmpty();
} catch (Exception e) {
return false;
}
}
private boolean isAnyStoreUnderReview(AppVersionEntity v) {
if (v.getStoreReviewStatus() == null) return false;
try {
@SuppressWarnings("unchecked")
Map<String, Map<String, Object>> reviewMap =
mapper.readValue(v.getStoreReviewStatus(), new TypeReference<Map<String, Map<String, Object>>>() {});
for (Map<String, Object> info : reviewMap.values()) {
String state = info.get("state") instanceof String s ? s : "";
if ("SUBMITTING".equals(state) || "UNDER_REVIEW".equals(state)) {
return true;
}
}
} catch (Exception e) {
return false;
}
return false;
}
private void huaweiUpdateReleasePlan(AppVersionEntity v, Map<String, String> creds,
String publishType, java.time.LocalDateTime scheduledAt) throws Exception {
String clientId = require(creds, "clientId", "HUAWEI");
@ -1423,32 +1690,69 @@ public class StoreSubmissionService {
Map.of("name", "apk", "hash", md5Hex(apkFile))
));
// Use curl ProcessBuilder: MI API server drops large multipart body with RestTemplate.
// curl sends Expect:100-continue which bypasses the server-side body timeout.
// --http1.1: MI API has HTTP/2 stream framing issues (curl exit 92); force HTTP/1.1.
// MI API drops large multipart bodies with RestTemplate in some environments.
// Build a multipart temp body and send it with Java HttpClient so containers do
// not depend on a system curl binary. Force HTTP/1.1 to avoid MI HTTP/2 issues.
String requestDataJson = asJsonString(requestData);
String sigJson = rsaEncryptHexChunked(asJsonString(sig), publicKey);
ProcessBuilder pb = new ProcessBuilder(
"curl", "-s", "--http1.1", "--connect-timeout", "30",
"--max-time", String.valueOf(130 * 60),
"-F", "apk=@" + apkFile.getAbsolutePath(),
"-F", "RequestData=" + requestDataJson,
"-F", "SIG=" + sigJson,
"https://api.developer.xiaomi.com/devupload/dev/push"
);
pb.redirectErrorStream(true);
Process process = pb.start();
String responseBody = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim();
boolean completed = process.waitFor(130, java.util.concurrent.TimeUnit.MINUTES);
if (!completed) {
process.destroyForcibly();
throw new IllegalStateException("curl upload to MI timed out");
String boundary = "----XuqmMiUpload" + UUID.randomUUID();
Path multipartFile = Files.createTempFile("xuqm-mi-upload-", ".multipart");
try {
writeMiMultipartBody(multipartFile, boundary, apkFile, requestDataJson, sigJson);
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.developer.xiaomi.com/devupload/dev/push"))
.timeout(Duration.ofMinutes(130))
.version(HttpClient.Version.HTTP_1_1)
.expectContinue(true)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofFile(multipartFile))
.build();
HttpResponse<String> response = miHttp.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String responseBody = response.body() == null ? "" : response.body().trim();
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IllegalStateException("MI upload HTTP " + response.statusCode() + ": " + responseBody);
}
if (responseBody.isEmpty()) {
throw new IllegalStateException("MI upload returned empty response");
}
JsonNode root = mapper.readTree(responseBody);
miCheckSuccess(root, "上传Apk");
} finally {
try {
Files.deleteIfExists(multipartFile);
} catch (Exception e) {
log.warn("Failed to delete MI multipart temp file {}: {}", multipartFile, e.getMessage());
}
}
if (responseBody.isEmpty()) {
throw new IllegalStateException("curl upload to MI returned empty response (exit=" + process.exitValue() + ")");
}
private void writeMiMultipartBody(Path target,
String boundary,
File apkFile,
String requestDataJson,
String sigJson) throws Exception {
try (OutputStream out = Files.newOutputStream(target)) {
out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"apk\"; filename=\"" + apkFile.getName() + "\"\r\n")
.getBytes(StandardCharsets.UTF_8));
out.write("Content-Type: application/vnd.android.package-archive\r\n\r\n".getBytes(StandardCharsets.UTF_8));
Files.copy(apkFile.toPath(), out);
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
writeMultipartText(out, boundary, "RequestData", requestDataJson);
writeMultipartText(out, boundary, "SIG", sigJson);
out.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
}
JsonNode root = mapper.readTree(responseBody);
miCheckSuccess(root, "上传Apk");
}
private void writeMultipartText(OutputStream out,
String boundary,
String name,
String value) throws Exception {
out.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
out.write(("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
out.write((value == null ? "" : value).getBytes(StandardCharsets.UTF_8));
out.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
private String resolveMiAccount(Map<String, String> creds) {
@ -1979,6 +2283,22 @@ public class StoreSubmissionService {
return "";
}
private String stringValue(Object value) {
if (value == null) return "";
String text = String.valueOf(value).trim();
return "null".equalsIgnoreCase(text) ? "" : text;
}
private String firstNonBlank(String... values) {
if (values == null) return "";
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return "";
}
private String requiredText(Map<String, Object> map, String key, String action) {
if (map == null) {
throw new IllegalStateException(action + " failed: empty response body");
@ -2064,6 +2384,40 @@ public class StoreSubmissionService {
payload);
}
/**
* Sanitise a raw API response before logging / storing it.
* Strips tokens, secrets, and other sensitive fields.
*/
private String sanitizeJson(Object raw) {
if (raw == null) return "";
try {
String text = raw instanceof String s ? s : mapper.writeValueAsString(raw);
if (text.isBlank()) return "";
// Simple string replacement to mask sensitive values
String[] sensitiveKeys = {
"access_token", "accessToken", "token", "client_secret", "clientSecret",
"secret", "password", "privateKey", "api_sign", "sig", "sign"
};
String result = text;
for (String key : sensitiveKeys) {
// Match "key":"value" or key=value patterns
result = result.replaceAll(
"(?i)(\\\"" + key + "\\\":\\\")[^\\\"]{4,}\\\"",
"\\\"" + key + "\\\":\"***\"");
result = result.replaceAll(
"(?i)(" + key + "=)[^&\\s]{4,}",
key + "=***");
}
// Truncate very large responses
if (result.length() > 8000) {
result = result.substring(0, 8000) + "... [truncated]";
}
return result;
} catch (Exception e) {
return "";
}
}
private String describeException(Throwable throwable) {
if (throwable == null) {
return "unknown error";