docs(private): 完善私有化部署开发计划和设计规范

- 增加实时进度和交接规则,定义任务状态枚举和更新格式
- 创建任务进度台账,涵盖P0-P5阶段全部开发任务
- 补充部署仓库交付边界确认和进度审计规范
- 完善MySQL/Redis双模式支持,增加external/managed选项
- 增加离线部署、安全治理、可观测性等完整交付能力
- 更新仓库结构设计,增加secrets.env、observability、data目录
- 补充健康检查、诊断脚本、升级回滚、备份恢复详细要求
- 优化应用商店审核状态查询逻辑,增加手动刷新接口
- 修复小米和VIVO商店状态查询中的版本匹配逻辑错误
- 增加缓存键版本隔离,防止不同版本状态混淆
- 优化厂商API连通性检查和审核状态轮询机制
这个提交包含在:
XuqmGroup 2026-05-18 19:00:38 +08:00
父节点 87edb316a5
当前提交 93fdb31cdc
共有 4 个文件被更改,包括 314 次插入10 次删除

查看文件

@ -194,6 +194,20 @@ public class AppStoreController {
return ResponseEntity.ok(ApiResponse.success(null)); 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 // Modify publish schedule
/** /**

查看文件

@ -644,6 +644,11 @@ public class AppStoreService {
return false; return false;
} }
if (entry instanceof Map<?, ?> map) { 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"); Object currentSubmissionLive = map.get("currentSubmissionLive");
return !(currentSubmissionLive instanceof Boolean b) || b; return !(currentSubmissionLive instanceof Boolean b) || b;
} }

查看文件

@ -519,21 +519,29 @@ public class StoreSubmissionService {
int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1)); int appStatus = pkg.path("appStatus").asInt(pkg.path("status").asInt(-1));
boolean updateVersion = root.path("updateVersion").asBoolean(false); boolean updateVersion = root.path("updateVersion").asBoolean(false);
String submittedCode = String.valueOf(v.getVersionCode()); 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; StoreRemoteState.ReviewState reviewState;
if (appStatus == 5) { if (appStatus == 5) {
reviewState = StoreRemoteState.ReviewState.REJECTED; reviewState = StoreRemoteState.ReviewState.REJECTED;
} else if (updateVersion) { } else if (currentSubmissionLive) {
reviewState = StoreRemoteState.ReviewState.ONLINE; reviewState = StoreRemoteState.ReviewState.ONLINE;
} else { } else {
// Keep UNDER_REVIEW when no explicit rejection for current version.
// updateVersion alone is unreliable for version attribution.
reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW_XIAOMI; 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( return StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.MI, AppStoreConfigEntity.StoreType.MI,
reviewState, reviewState,
onlineVersionName, onlineVersionCode, onlineVersionName, onlineVersionCode,
"", "", "", "",
isLive, !isLive && !onlineVersionCode.isBlank(), currentSubmissionLive, nonCurrentRelease,
true, "", sanitizeJson(root)); true, "", sanitizeJson(root));
} }
@ -569,7 +577,9 @@ public class StoreSubmissionService {
String accessKey = require(creds, "accessKey", "VIVO"); String accessKey = require(creds, "accessKey", "VIVO");
String accessSecret = require(creds, "accessSecret", "VIVO"); String accessSecret = require(creds, "accessSecret", "VIVO");
String pkg = requirePackageName(v); 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); VivoCacheEntry cached = vivoQueryCache.get(cacheKey);
if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) { if (cached != null && System.currentTimeMillis() - cached.timestamp < VIVO_CACHE_TTL_MS) {
log.debug("VIVO query cache hit for {}", cacheKey); log.debug("VIVO query cache hit for {}", cacheKey);
@ -596,13 +606,17 @@ public class StoreSubmissionService {
} else { } else {
reviewState = StoreRemoteState.ReviewState.UNKNOWN; 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( StoreRemoteState state = StoreRemoteState.ok(
AppStoreConfigEntity.StoreType.VIVO, AppStoreConfigEntity.StoreType.VIVO,
reviewState, reviewState,
onlineVersionName, onlineVersionCode, onlineVersionName, onlineVersionCode,
"", "", "", "",
isLive && submittedCode.equals(onlineVersionCode), currentSubmissionLive,
isLive && !submittedCode.equals(onlineVersionCode), nonCurrentRelease,
true, "", sanitizeJson(root)); true, "", sanitizeJson(root));
vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis())); vivoQueryCache.put(cacheKey, new VivoCacheEntry(state, System.currentTimeMillis()));
return state; return state;
@ -701,11 +715,17 @@ public class StoreSubmissionService {
log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType); log.debug("Store review poll: {}/{} still UNDER_REVIEW", v.getId(), storeType);
} }
} else { } else {
if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) { // REJECTED: only transition to APPROVED if the CURRENT submission is now live.
log.info("Store review poll: {}/{} was REJECTED but store has live version currentSubmissionLive={} onlineVersionName={} onlineVersionCode={}", // If a different version is online (nonCurrentRelease), keep REJECTED
v.getId(), storeType, polled.isCurrentSubmissionLive(), polled.getOnlineVersionName(), polled.getOnlineVersionCode()); // the rejection of the current submission is still valid.
storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), 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)); 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 // 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) { private AppVersionEntity.StoreReviewState mapToStoreReviewState(StoreRemoteState.ReviewState reviewState) {
return switch (reviewState) { return switch (reviewState) {
case ONLINE -> AppVersionEntity.StoreReviewState.APPROVED; 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);
}
}