docs(private): 完善私有化部署开发计划和设计规范
- 增加实时进度和交接规则,定义任务状态枚举和更新格式 - 创建任务进度台账,涵盖P0-P5阶段全部开发任务 - 补充部署仓库交付边界确认和进度审计规范 - 完善MySQL/Redis双模式支持,增加external/managed选项 - 增加离线部署、安全治理、可观测性等完整交付能力 - 更新仓库结构设计,增加secrets.env、observability、data目录 - 补充健康检查、诊断脚本、升级回滚、备份恢复详细要求 - 优化应用商店审核状态查询逻辑,增加手动刷新接口 - 修复小米和VIVO商店状态查询中的版本匹配逻辑错误 - 增加缓存键版本隔离,防止不同版本状态混淆 - 优化厂商API连通性检查和审核状态轮询机制
这个提交包含在:
父节点
87edb316a5
当前提交
93fdb31cdc
@ -194,6 +194,20 @@ public class AppStoreController {
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the review status for all target stores of a version.
|
||||
* Queries each store's remote API and updates the local state if it has changed.
|
||||
*/
|
||||
@PostMapping("/app/{versionId}/refresh-review-status")
|
||||
public ResponseEntity<ApiResponse<com.xuqm.update.model.PreflightSubmitResult>> refreshReviewStatus(
|
||||
@PathVariable String versionId) {
|
||||
AppVersionEntity v = submissionService.getVersionForPreflight(versionId);
|
||||
List<com.xuqm.update.model.StoreRemoteState> states = submissionService.refreshStoreReviewStatus(versionId);
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
com.xuqm.update.model.PreflightSubmitResult.of(
|
||||
versionId, v.getVersionName(), v.getVersionCode(), states)));
|
||||
}
|
||||
|
||||
// ── Modify publish schedule ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@ -644,6 +644,11 @@ public class AppStoreService {
|
||||
return false;
|
||||
}
|
||||
if (entry instanceof Map<?, ?> map) {
|
||||
// Exclude non-current releases from auto-publish
|
||||
Object nonCurrentRelease = map.get("nonCurrentRelease");
|
||||
if (nonCurrentRelease instanceof Boolean b && b) {
|
||||
return false;
|
||||
}
|
||||
Object currentSubmissionLive = map.get("currentSubmissionLive");
|
||||
return !(currentSubmissionLive instanceof Boolean b) || b;
|
||||
}
|
||||
|
||||
@ -519,21 +519,29 @@ public class StoreSubmissionService {
|
||||
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);
|
||||
// updateVersion=true means "there is a live version" but NOT necessarily the current submission.
|
||||
// Only treat as live for the CURRENT submission when onlineVersionCode matches submittedCode.
|
||||
boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank();
|
||||
boolean hasOnlineVersion = updateVersion || !onlineVersionCode.isBlank();
|
||||
boolean nonCurrentRelease = hasOnlineVersion && !currentSubmissionLive;
|
||||
StoreRemoteState.ReviewState reviewState;
|
||||
if (appStatus == 5) {
|
||||
reviewState = StoreRemoteState.ReviewState.REJECTED;
|
||||
} else if (updateVersion) {
|
||||
} else if (currentSubmissionLive) {
|
||||
reviewState = StoreRemoteState.ReviewState.ONLINE;
|
||||
} else {
|
||||
// Keep UNDER_REVIEW when no explicit rejection for current version.
|
||||
// updateVersion alone is unreliable for version attribution.
|
||||
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI;
|
||||
}
|
||||
log.info("MI remote state queried: versionId={} submittedCode={} onlineVersionCode={} onlineVersionName={} appStatus={} updateVersion={} reviewState={} currentSubmissionLive={} nonCurrentRelease={}",
|
||||
v.getId(), submittedCode, onlineVersionCode, onlineVersionName, appStatus, updateVersion, reviewState, currentSubmissionLive, nonCurrentRelease);
|
||||
return StoreRemoteState.ok(
|
||||
AppStoreConfigEntity.StoreType.MI,
|
||||
reviewState,
|
||||
onlineVersionName, onlineVersionCode,
|
||||
"", "",
|
||||
isLive, !isLive && !onlineVersionCode.isBlank(),
|
||||
currentSubmissionLive, nonCurrentRelease,
|
||||
true, "", sanitizeJson(root));
|
||||
}
|
||||
|
||||
@ -569,7 +577,9 @@ public class StoreSubmissionService {
|
||||
String accessKey = require(creds, "accessKey", "VIVO");
|
||||
String accessSecret = require(creds, "accessSecret", "VIVO");
|
||||
String pkg = requirePackageName(v);
|
||||
String cacheKey = v.getAppKey() + ":" + pkg;
|
||||
// Cache key includes versionCode because StoreRemoteState contains version-specific
|
||||
// fields (currentSubmissionLive, nonCurrentRelease) that differ per version.
|
||||
String cacheKey = v.getAppKey() + ":" + pkg + ":" + v.getVersionCode();
|
||||
VivoCacheEntry cached = vivoQueryCache.get(cacheKey);
|
||||
if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) {
|
||||
log.debug("VIVO query cache hit for {}", cacheKey);
|
||||
@ -596,13 +606,17 @@ public class StoreSubmissionService {
|
||||
} else {
|
||||
reviewState = StoreRemoteState.ReviewState.UNKNOWN;
|
||||
}
|
||||
boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode);
|
||||
boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode);
|
||||
log.info("VIVO remote state queried: versionId={} submittedCode={} onlineVersionCode={} onlineVersionName={} status={} reviewState={} currentSubmissionLive={} nonCurrentRelease={}",
|
||||
v.getId(), submittedCode, onlineVersionCode, onlineVersionName, status, reviewState, currentSubmissionLive, nonCurrentRelease);
|
||||
StoreRemoteState state = StoreRemoteState.ok(
|
||||
AppStoreConfigEntity.StoreType.VIVO,
|
||||
reviewState,
|
||||
onlineVersionName, onlineVersionCode,
|
||||
"", "",
|
||||
isLive && submittedCode.equals(onlineVersionCode),
|
||||
isLive && !submittedCode.equals(onlineVersionCode),
|
||||
currentSubmissionLive,
|
||||
nonCurrentRelease,
|
||||
true, "", sanitizeJson(root));
|
||||
vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis()));
|
||||
return state;
|
||||
@ -701,11 +715,17 @@ public class StoreSubmissionService {
|
||||
log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType);
|
||||
}
|
||||
} else {
|
||||
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(),
|
||||
// REJECTED: only transition to APPROVED if the CURRENT submission is now live.
|
||||
// If a different version is online (nonCurrentRelease), keep REJECTED —
|
||||
// the rejection of the current submission is still valid.
|
||||
if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isCurrentSubmissionLive()) {
|
||||
log.info("Store review poll: {}/{} was REJECTED but current submission is now live",
|
||||
v.getId(), storeType);
|
||||
storeService.updateStoreReviewLive(v.getId(), storeType, false,
|
||||
buildLiveReason(polled), buildExtra(polled));
|
||||
} else if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isNonCurrentRelease()) {
|
||||
log.debug("Store review poll: {}/{} was REJECTED and a different version is online — leaving REJECTED",
|
||||
v.getId(), storeType);
|
||||
}
|
||||
// otherwise leave REJECTED as-is
|
||||
}
|
||||
@ -716,6 +736,102 @@ public class StoreSubmissionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the review status for all target stores of a given version.
|
||||
* Unlike the scheduled poll, this works for any state (not just UNDER_REVIEW/REJECTED)
|
||||
* and returns the refreshed states to the caller.
|
||||
*/
|
||||
public List<StoreRemoteState> refreshStoreReviewStatus(String versionId) {
|
||||
AppVersionEntity v = versionRepo.findById(versionId).orElse(null);
|
||||
if (v == null) {
|
||||
throw new IllegalArgumentException("Version not found: " + versionId);
|
||||
}
|
||||
Map<String, Object> reviewMap;
|
||||
try {
|
||||
reviewMap = v.getStoreReviewStatus() != null
|
||||
? mapper.readValue(v.getStoreReviewStatus(), new com.fasterxml.jackson.core.type.TypeReference<>() {})
|
||||
: new LinkedHashMap<>();
|
||||
} catch (Exception e) {
|
||||
reviewMap = new LinkedHashMap<>();
|
||||
}
|
||||
List<String> targets = parseTargets(v.getStoreSubmitTargets());
|
||||
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 polled = queryRemoteState(storeType, v, creds);
|
||||
if (polled == null) {
|
||||
results.add(StoreRemoteState.failed(
|
||||
AppStoreConfigEntity.StoreType.valueOf(storeType),
|
||||
"No poll API for this store",
|
||||
"该厂商不支持状态查询"));
|
||||
continue;
|
||||
}
|
||||
String existingState = "";
|
||||
Object entry = reviewMap.get(storeType);
|
||||
if (entry instanceof Map<?, ?> m) {
|
||||
Object s = m.get("state");
|
||||
existingState = s instanceof String str ? str : "";
|
||||
} else if (entry instanceof String s) {
|
||||
existingState = s;
|
||||
}
|
||||
boolean isUnderReview = "UNDER_REVIEW".equals(existingState);
|
||||
boolean isRejected = "REJECTED".equals(existingState);
|
||||
AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState());
|
||||
if (isUnderReview) {
|
||||
if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) {
|
||||
log.info("Manual refresh: {}/{} status changed from UNDER_REVIEW 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, mappedState, "手动刷新厂商审核状态");
|
||||
}
|
||||
}
|
||||
} else if (isRejected) {
|
||||
if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isCurrentSubmissionLive()) {
|
||||
log.info("Manual refresh: {}/{} was REJECTED but current submission is now live", v.getId(), storeType);
|
||||
storeService.updateStoreReviewLive(v.getId(), storeType, false,
|
||||
buildLiveReason(polled), buildExtra(polled));
|
||||
} else if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE && polled.isNonCurrentRelease()) {
|
||||
log.info("Manual refresh: {}/{} was REJECTED and a different version is online — leaving REJECTED", v.getId(), storeType);
|
||||
}
|
||||
} else {
|
||||
// For other states (PENDING, SUBMITTING, APPROVED, WITHDRAWN),
|
||||
// just record the polled state without modifying the database.
|
||||
log.debug("Manual refresh: {}/{} existing={} polled={} — no state transition applied",
|
||||
v.getId(), storeType, existingState, polled.getReviewState());
|
||||
}
|
||||
results.add(polled);
|
||||
} catch (Exception e) {
|
||||
log.warn("Manual refresh query failed for {}/{}: {}", v.getId(), storeType, e.getMessage());
|
||||
results.add(StoreRemoteState.failed(
|
||||
AppStoreConfigEntity.StoreType.valueOf(storeType),
|
||||
describeException(e),
|
||||
"查询失败,不能确认状态"));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private AppVersionEntity.StoreReviewState mapToStoreReviewState(StoreRemoteState.ReviewState reviewState) {
|
||||
return switch (reviewState) {
|
||||
case ONLINE -> AppVersionEntity.StoreReviewState.APPROVED;
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
package com.xuqm.update.service;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for vendor API response parsing logic.
|
||||
* Verifies Xiaomi and Vivo edge cases with non-current releases.
|
||||
*/
|
||||
class StoreRemoteStateParsingTest {
|
||||
|
||||
private static final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void xiaomiUpdateVersionTrueButDifferentOnlineVersion() throws Exception {
|
||||
// Xiaomi returns updateVersion=true but onlineVersionCode is for an older release
|
||||
String json = "{\"packageInfo\":{\"versionCode\":\"260513531\",\"versionName\":\"7.2.10\",\"appStatus\":1},\"updateVersion\":true}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode pkg = root.path("packageInfo");
|
||||
|
||||
String onlineVersionCode = pkg.path("versionCode").asText("");
|
||||
String onlineVersionName = pkg.path("versionName").asText("");
|
||||
int appStatus = pkg.path("appStatus").asInt(-1);
|
||||
boolean updateVersion = root.path("updateVersion").asBoolean(false);
|
||||
String submittedCode = "260518336";
|
||||
|
||||
boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank();
|
||||
boolean hasOnlineVersion = updateVersion || !onlineVersionCode.isBlank();
|
||||
boolean nonCurrentRelease = hasOnlineVersion && !currentSubmissionLive;
|
||||
|
||||
StoreRemoteState.ReviewState reviewState;
|
||||
if (appStatus == 5) {
|
||||
reviewState = StoreRemoteState.ReviewState.REJECTED;
|
||||
} else if (currentSubmissionLive) {
|
||||
reviewState = StoreRemoteState.ReviewState.ONLINE;
|
||||
} else {
|
||||
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI;
|
||||
}
|
||||
|
||||
assertFalse(currentSubmissionLive, "Current submission should NOT be live when version codes differ");
|
||||
assertTrue(nonCurrentRelease, "Should detect non-current release when online version differs");
|
||||
assertEquals(StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI, reviewState,
|
||||
"Should remain UNDER_REVIEW when updateVersion=true but online version is different");
|
||||
}
|
||||
|
||||
@Test
|
||||
void xiaomiCurrentSubmissionLive() throws Exception {
|
||||
String json = "{\"packageInfo\":{\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\",\"appStatus\":2},\"updateVersion\":true}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode pkg = root.path("packageInfo");
|
||||
|
||||
String onlineVersionCode = pkg.path("versionCode").asText("");
|
||||
String submittedCode = "260518336";
|
||||
int appStatus = pkg.path("appStatus").asInt(-1);
|
||||
boolean updateVersion = root.path("updateVersion").asBoolean(false);
|
||||
|
||||
boolean currentSubmissionLive = submittedCode.equals(onlineVersionCode) && !submittedCode.isBlank();
|
||||
StoreRemoteState.ReviewState reviewState;
|
||||
if (appStatus == 5) {
|
||||
reviewState = StoreRemoteState.ReviewState.REJECTED;
|
||||
} else if (currentSubmissionLive) {
|
||||
reviewState = StoreRemoteState.ReviewState.ONLINE;
|
||||
} else {
|
||||
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI;
|
||||
}
|
||||
|
||||
assertTrue(currentSubmissionLive, "Current submission should be live when version codes match");
|
||||
assertEquals(StoreRemoteState.ReviewState.ONLINE, reviewState,
|
||||
"Should be ONLINE when current submission matches online version");
|
||||
}
|
||||
|
||||
@Test
|
||||
void xiaomiExplicitRejection() throws Exception {
|
||||
String json = "{\"packageInfo\":{\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\",\"appStatus\":5},\"updateVersion\":false}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode pkg = root.path("packageInfo");
|
||||
|
||||
int appStatus = pkg.path("appStatus").asInt(-1);
|
||||
StoreRemoteState.ReviewState reviewState;
|
||||
if (appStatus == 5) {
|
||||
reviewState = StoreRemoteState.ReviewState.REJECTED;
|
||||
} else {
|
||||
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI;
|
||||
}
|
||||
|
||||
assertEquals(StoreRemoteState.ReviewState.REJECTED, reviewState,
|
||||
"appStatus=5 should always be REJECTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vivoNonCurrentRelease() throws Exception {
|
||||
// Vivo returns status=3 (online) but versionCode is for an older release
|
||||
String json = "{\"data\":{\"status\":3,\"versionCode\":\"260513531\",\"versionName\":\"7.2.10\"}}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode data = root.path("data");
|
||||
|
||||
int status = data.path("status").asInt(-1);
|
||||
String onlineVersionCode = data.path("versionCode").asText("");
|
||||
String submittedCode = "260518336";
|
||||
boolean isLive = status == 3;
|
||||
|
||||
boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode);
|
||||
boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode);
|
||||
|
||||
assertFalse(currentSubmissionLive, "Current submission should NOT be live when version codes differ");
|
||||
assertTrue(nonCurrentRelease, "Should detect non-current release when online version differs");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vivoCurrentSubmissionLive() throws Exception {
|
||||
String json = "{\"data\":{\"status\":3,\"versionCode\":\"260518336\",\"versionName\":\"7.2.10\"}}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode data = root.path("data");
|
||||
|
||||
int status = data.path("status").asInt(-1);
|
||||
String onlineVersionCode = data.path("versionCode").asText("");
|
||||
String submittedCode = "260518336";
|
||||
boolean isLive = status == 3;
|
||||
|
||||
boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode);
|
||||
boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode);
|
||||
|
||||
assertTrue(currentSubmissionLive, "Current submission should be live when version codes match");
|
||||
assertFalse(nonCurrentRelease, "Should NOT be non-current release when version codes match");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vivoMissingVersionCodeDefaultsToNonCurrentRelease() throws Exception {
|
||||
// Vivo returns status=3 but no versionCode - safest assumption is non-current release
|
||||
String json = "{\"data\":{\"status\":3,\"versionName\":\"7.2.10\"}}";
|
||||
JsonNode root = mapper.readTree(json);
|
||||
JsonNode data = root.path("data");
|
||||
|
||||
int status = data.path("status").asInt(-1);
|
||||
String onlineVersionCode = data.path("versionCode").asText("");
|
||||
String submittedCode = "260518336";
|
||||
boolean isLive = status == 3;
|
||||
|
||||
boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode);
|
||||
boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode);
|
||||
|
||||
assertFalse(currentSubmissionLive, "Should not be current submission live when versionCode is missing");
|
||||
assertTrue(nonCurrentRelease, "Should default to non-current release when versionCode is missing but status is online");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void allApprovedExcludesNonCurrentRelease() throws Exception {
|
||||
// Simulate reviewMap with a store that is APPROVED but nonCurrentRelease=true
|
||||
String reviewJson = "{\"VIVO\":{\"state\":\"APPROVED\",\"nonCurrentRelease\":true,\"currentSubmissionLive\":false}}";
|
||||
java.util.Map<String, Object> reviewMap = mapper.readValue(reviewJson, java.util.Map.class);
|
||||
|
||||
// The allApproved logic should reject this because nonCurrentRelease=true
|
||||
Object entry = reviewMap.get("VIVO");
|
||||
assertTrue(entry instanceof java.util.Map<?, ?>);
|
||||
java.util.Map<?, ?> map = (java.util.Map<?, ?>) entry;
|
||||
Object nonCurrentRelease = map.get("nonCurrentRelease");
|
||||
assertTrue(nonCurrentRelease instanceof Boolean && (Boolean) nonCurrentRelease);
|
||||
|
||||
Object currentSubmissionLive = map.get("currentSubmissionLive");
|
||||
assertTrue(currentSubmissionLive instanceof Boolean && !(Boolean) currentSubmissionLive);
|
||||
}
|
||||
}
|
||||
正在加载...
在新工单中引用
屏蔽一个用户