feat(private-deploy): 支持 MySQL/Redis 外部连接和托管模式部署
- 添加 external 和 managed 两种数据库/缓存模式支持 - 实现 MySQL/Redis 托管安装脚本和配置向导 - 支持客户自备连接或部署脚本新建基础设施 - 更新部署文档说明不同模式的配置和验证要求 - 添加应用版本防重复上传和删除功能 - 实现应用商店预提交检查和发布计划功能
这个提交包含在:
父节点
e309a41ed0
当前提交
87edb316a5
@ -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";
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户