一大波改动
这个提交包含在:
父节点
bc1165d22e
当前提交
0ed09a8229
@ -114,18 +114,19 @@ public class AppStoreController {
|
||||
|
||||
List<String> storeTypes = body != null ? extractStringList(body, "storeTypes") : null;
|
||||
String submitMode = body != null ? (String) body.get("submitMode") : null;
|
||||
String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null;
|
||||
String scheduledPublishAtText = body != null ? (String) body.get("scheduledPublishAt") : null;
|
||||
Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null
|
||||
? Boolean.valueOf(body.get("autoPublishAfterReview").toString()) : null;
|
||||
java.time.LocalDateTime scheduledAt = null;
|
||||
if (scheduledAtText != null && !scheduledAtText.isBlank()) {
|
||||
scheduledAt = java.time.LocalDateTime.parse(scheduledAtText);
|
||||
java.time.LocalDateTime scheduledPublishAt = null;
|
||||
if (scheduledPublishAtText != null && !scheduledPublishAtText.isBlank()) {
|
||||
scheduledPublishAt = java.time.LocalDateTime.parse(scheduledPublishAtText);
|
||||
}
|
||||
AppVersionEntity v = storeService.markSubmitted(versionId,
|
||||
storeTypes,
|
||||
submitMode,
|
||||
scheduledAt,
|
||||
autoPublishAfterReview);
|
||||
null,
|
||||
autoPublishAfterReview,
|
||||
scheduledPublishAt);
|
||||
String normalizedMode = submitMode == null ? "MANUAL" : submitMode.trim().toUpperCase();
|
||||
if (!"SCHEDULED".equals(normalizedMode)) {
|
||||
submissionService.executeSubmitAsync(versionId);
|
||||
@ -161,6 +162,41 @@ public class AppStoreController {
|
||||
storeService.updateStoreReview(versionId, storeType, state, reason)));
|
||||
}
|
||||
|
||||
// ── Cancel review (撤回审核) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Withdraw app from store review.
|
||||
* Body: { "storeTypes": ["HUAWEI", "MI"] } — optional, defaults to all active-review stores.
|
||||
*/
|
||||
@PostMapping("/app/{versionId}/cancel-review")
|
||||
public ResponseEntity<ApiResponse<Void>> cancelReview(
|
||||
@PathVariable String versionId,
|
||||
@RequestBody(required = false) Map<String, Object> body) throws Exception {
|
||||
|
||||
List<String> storeTypes = body != null ? extractStringList(body, "storeTypes") : null;
|
||||
submissionService.cancelStoreReview(versionId, storeTypes);
|
||||
return ResponseEntity.ok(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
// ── Modify publish schedule ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change when an approved version goes live.
|
||||
* Body: { "publishType": "IMMEDIATE" | "SCHEDULED", "scheduledAt": "2026-05-20T10:00:00" }
|
||||
*/
|
||||
@PutMapping("/app/{versionId}/publish-schedule")
|
||||
public ResponseEntity<ApiResponse<AppVersionEntity>> updatePublishSchedule(
|
||||
@PathVariable String versionId,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
|
||||
String publishType = body.get("publishType") instanceof String s ? s : "IMMEDIATE";
|
||||
String scheduledAtText = body.get("scheduledAt") instanceof String s ? s : null;
|
||||
java.time.LocalDateTime scheduledAt = scheduledAtText != null && !scheduledAtText.isBlank()
|
||||
? java.time.LocalDateTime.parse(scheduledAtText) : null;
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
submissionService.updatePublishSchedule(versionId, publishType, scheduledAt)));
|
||||
}
|
||||
|
||||
private List<String> extractStringList(Map<String, Object> body, String key) {
|
||||
Object value = body.get(key);
|
||||
if (value instanceof List<?> list) {
|
||||
|
||||
@ -115,6 +115,15 @@ public class AppStoreService {
|
||||
String submitMode,
|
||||
LocalDateTime scheduledAt,
|
||||
Boolean autoPublishAfterReview) throws Exception {
|
||||
return markSubmitted(versionId, storeTypes, submitMode, scheduledAt, autoPublishAfterReview, null);
|
||||
}
|
||||
|
||||
public AppVersionEntity markSubmitted(String versionId,
|
||||
List<String> storeTypes,
|
||||
String submitMode,
|
||||
LocalDateTime scheduledAt,
|
||||
Boolean autoPublishAfterReview,
|
||||
LocalDateTime scheduledPublishAt) throws Exception {
|
||||
synchronized (lockFor(versionId)) {
|
||||
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||
List<String> resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes);
|
||||
@ -130,7 +139,8 @@ public class AppStoreService {
|
||||
// Withdraw lower versions' active reviews for the same stores
|
||||
cancelSupersededVersionReviews(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets);
|
||||
|
||||
Map<String, Object> reviewMap = new LinkedHashMap<>();
|
||||
// Merge with existing store statuses instead of replacing — preserves other stores' states
|
||||
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
||||
for (String store : resolvedTargets) {
|
||||
reviewMap.put(store, reviewPayload(
|
||||
AppVersionEntity.StoreReviewState.PENDING.name(),
|
||||
@ -144,6 +154,9 @@ public class AppStoreService {
|
||||
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
||||
v.setStoreSubmitMode(normalizedMode);
|
||||
v.setStoreSubmitScheduledAt(scheduledAt);
|
||||
if (scheduledPublishAt != null) {
|
||||
v.setScheduledPublishAt(scheduledPublishAt);
|
||||
}
|
||||
if (autoPublishAfterReview != null) {
|
||||
v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode));
|
||||
}
|
||||
|
||||
@ -324,12 +324,23 @@ public class StoreSubmissionService {
|
||||
// 5. Bind APK
|
||||
String pkgId = huaweiBindApk(clientId, token, hwAppId, file.getName(), objectId);
|
||||
|
||||
// 6. Wait for compile (poll up to 3 minutes)
|
||||
huaweiWaitCompile(clientId, token, hwAppId, pkgId);
|
||||
// 6. Brief compile check (up to 5 min) — HUAWEI queues compile+review internally so
|
||||
// we submit regardless of compile status after the grace period.
|
||||
huaweiWaitCompileOrContinue(clientId, token, hwAppId, pkgId);
|
||||
|
||||
// 7. Update version description
|
||||
huaweiUpdateDesc(clientId, token, hwAppId, v.getChangeLog());
|
||||
|
||||
// 7.5. Set planned release date if specified
|
||||
if (v.getScheduledPublishAt() != null) {
|
||||
try {
|
||||
huaweiUpdateReleasePlan(v, creds, "SCHEDULED", v.getScheduledPublishAt());
|
||||
log.info("Huawei release plan set to SCHEDULED at {}", v.getScheduledPublishAt());
|
||||
} catch (Exception e) {
|
||||
log.warn("Huawei release plan update failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Submit for review
|
||||
huaweiSubmit(clientId, token, hwAppId);
|
||||
}
|
||||
@ -436,28 +447,50 @@ public class StoreSubmissionService {
|
||||
return pkgId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls HUAWEI compile status for up to 5 minutes.
|
||||
* If compile finishes (status=2) we return early; status=3 (failed) throws.
|
||||
* If the deadline passes without a clear result we log and continue anyway —
|
||||
* HUAWEI queues compile and review internally, so submitting before compile
|
||||
* finishes is safe for large APKs that take longer than our grace period.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void huaweiWaitCompile(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException {
|
||||
private void huaweiWaitCompileOrContinue(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException {
|
||||
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||
long deadline = System.currentTimeMillis() + 3 * 60_000L;
|
||||
long deadline = System.currentTimeMillis() + 5 * 60_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Thread.sleep(10_000);
|
||||
ResponseEntity<Map> resp = rest.exchange(
|
||||
HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId,
|
||||
HttpMethod.GET, new HttpEntity<>(headers), Map.class);
|
||||
Map<String, Object> respBody = requireBodyMap(resp.getBody(), "Huawei compile status");
|
||||
List<Map<String, Object>> states = asMapList(respBody.get("pkgStateList"));
|
||||
if (states.isEmpty()) {
|
||||
states = asMapList(respBody.get("data"));
|
||||
}
|
||||
if (states != null && !states.isEmpty()) {
|
||||
Object compileStatus = states.get(0).get("compileStatus");
|
||||
if ("2".equals(String.valueOf(compileStatus))) return; // 2 = success
|
||||
if ("3".equals(String.valueOf(compileStatus)))
|
||||
throw new RuntimeException("Huawei compile failed: " + summarizeMap(states.get(0)));
|
||||
Thread.sleep(15_000);
|
||||
try {
|
||||
ResponseEntity<Map> resp = rest.exchange(
|
||||
HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId,
|
||||
HttpMethod.GET, new HttpEntity<>(headers), Map.class);
|
||||
Map<String, Object> respBody = requireBodyMap(resp.getBody(), "Huawei compile status");
|
||||
log.info("Huawei compile status raw: {}", summarizeMap(respBody));
|
||||
List<Map<String, Object>> states = asMapList(respBody.get("pkgStateList"));
|
||||
if (states.isEmpty()) states = asMapList(respBody.get("data"));
|
||||
if (!states.isEmpty()) {
|
||||
Map<String, Object> s0 = states.get(0);
|
||||
// HUAWEI compile status API uses successStatus (0=pending,1=success,2=failed)
|
||||
// or legacy compileStatus (2=done,3=failed). Try both.
|
||||
Object compileStatus = s0.get("compileStatus");
|
||||
Object successStatus = s0.get("successStatus");
|
||||
log.info("Huawei compile state: compileStatus={} successStatus={} aabCompileStatus={}",
|
||||
compileStatus, successStatus, s0.get("aabCompileStatus"));
|
||||
if ("2".equals(String.valueOf(compileStatus))) return; // legacy done
|
||||
if ("3".equals(String.valueOf(compileStatus)))
|
||||
throw new RuntimeException("Huawei compile failed: " + summarizeMap(s0));
|
||||
if ("1".equals(String.valueOf(successStatus))) return; // new API done
|
||||
if ("2".equals(String.valueOf(successStatus)))
|
||||
throw new RuntimeException("Huawei compile failed (successStatus=2): " + summarizeMap(s0));
|
||||
} else {
|
||||
log.info("Huawei compile pkgStateList empty, keys={}", respBody.keySet());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getMessage() != null && e.getMessage().startsWith("Huawei compile failed")) throw e;
|
||||
log.warn("Huawei compile status poll error (continuing): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
throw new RuntimeException("Huawei compile timeout");
|
||||
log.warn("Huawei compile grace period elapsed for pkgId={} — submitting anyway", pkgId);
|
||||
}
|
||||
|
||||
private void huaweiUpdateDesc(String clientId, String token, String hwAppId, String changeLog) {
|
||||
@ -469,10 +502,23 @@ public class StoreSubmissionService {
|
||||
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void huaweiSubmit(String clientId, String token, String hwAppId) {
|
||||
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||
rest.postForEntity(HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId,
|
||||
ResponseEntity<Map> resp = rest.postForEntity(
|
||||
HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId,
|
||||
new HttpEntity<>(headers), Map.class);
|
||||
Map<String, Object> body = resp.getBody();
|
||||
log.info("Huawei app-submit response: {}", body != null ? summarizeMap(body) : "null");
|
||||
if (body != null) {
|
||||
Object ret = body.get("ret");
|
||||
if (ret instanceof Map<?, ?> retMap) {
|
||||
Object code = retMap.get("code");
|
||||
if (code != null && !"0".equals(String.valueOf(code))) {
|
||||
throw new RuntimeException("Huawei submit failed: " + retMap.get("msg") + " (code=" + code + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HttpHeaders huaweiHeaders(String clientId, String token) {
|
||||
@ -698,6 +744,181 @@ public class StoreSubmissionService {
|
||||
log.info("Google Play submission is handled by the release script or Play Console; server-side submission service only records the market link and review tracking");
|
||||
}
|
||||
|
||||
// ── Cancel review (撤回审核) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cancel pending store review for the specified stores.
|
||||
* Calls each store's withdrawal API (best-effort) then marks DB state as WITHDRAWN.
|
||||
*/
|
||||
public void cancelStoreReview(String versionId, List<String> storeTypes) throws Exception {
|
||||
AppVersionEntity v = versionRepo.findById(versionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId));
|
||||
|
||||
List<String> targets = (storeTypes != null && !storeTypes.isEmpty())
|
||||
? storeTypes
|
||||
: extractActiveReviewTargets(v);
|
||||
|
||||
for (String storeType : targets) {
|
||||
try {
|
||||
AppStoreConfigEntity cfg = configRepo
|
||||
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
|
||||
.orElse(null);
|
||||
if (cfg != null && cfg.isEnabled()) {
|
||||
try {
|
||||
Map<String, String> creds = parseConfig(cfg.getConfigJson());
|
||||
cancelAtStore(storeType, v, creds);
|
||||
} catch (Exception e) {
|
||||
log.warn("Cancel API call failed for {}/{}: {}", versionId, storeType, e.getMessage());
|
||||
}
|
||||
}
|
||||
storeService.updateStoreReview(versionId, storeType,
|
||||
AppVersionEntity.StoreReviewState.WITHDRAWN, "用户手动撤回");
|
||||
} catch (Exception e) {
|
||||
log.error("Cancel review failed for {}/{}: {}", versionId, storeType, e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> extractActiveReviewTargets(AppVersionEntity v) {
|
||||
if (v.getStoreReviewStatus() == null || v.getStoreReviewStatus().isBlank()) return List.of();
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Map<String, Object>> reviewMap =
|
||||
mapper.readValue(v.getStoreReviewStatus(), new TypeReference<>() {});
|
||||
return reviewMap.entrySet().stream()
|
||||
.filter(e -> {
|
||||
Object state = e.getValue().get("state");
|
||||
String s = state == null ? "" : state.toString();
|
||||
return "PENDING".equals(s) || "SUBMITTING".equals(s) || "UNDER_REVIEW".equals(s);
|
||||
})
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelAtStore(String storeType, AppVersionEntity v, Map<String, String> creds) throws Exception {
|
||||
switch (storeType) {
|
||||
case "HUAWEI" -> cancelAtHuawei(v, creds);
|
||||
case "HONOR" -> cancelAtHonor(v, creds);
|
||||
case "OPPO" -> cancelAtOppo(v, creds);
|
||||
case "VIVO" -> cancelAtVivo(v, creds);
|
||||
// MI: no public cancel API
|
||||
default -> log.info("No cancel API for store {}, DB-only withdrawal", storeType);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelAtHuawei(AppVersionEntity v, Map<String, String> creds) {
|
||||
String clientId = require(creds, "clientId", "HUAWEI");
|
||||
String clientSecret = require(creds, "clientSecret", "HUAWEI");
|
||||
String packageName = requirePackageName(v);
|
||||
String token = huaweiGetToken(clientId, clientSecret);
|
||||
String hwAppId = huaweiGetAppId(clientId, token, packageName);
|
||||
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||
rest.postForEntity(HUAWEI_API + "/api/publish/v2/developer-service/unSubmit?appId=" + hwAppId,
|
||||
new HttpEntity<>(headers), Map.class);
|
||||
log.info("HUAWEI unSubmit called for appId={}", hwAppId);
|
||||
}
|
||||
|
||||
private void cancelAtHonor(AppVersionEntity v, Map<String, String> creds) {
|
||||
String clientId = require(creds, "clientId", "HONOR");
|
||||
String clientSecret = require(creds, "clientSecret", "HONOR");
|
||||
String packageName = requirePackageName(v);
|
||||
String token = honorGetToken(clientId, clientSecret);
|
||||
int appId = honorGetAppId(token, packageName);
|
||||
HttpHeaders headers = honorHeaders(token);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
ResponseEntity<Map> resp = rest.postForEntity(
|
||||
HONOR_API + "/openapi/v1/publish/cancel-audit?appId=" + appId,
|
||||
new HttpEntity<>(headers), Map.class);
|
||||
assertHonorSuccess(resp.getBody(), "cancel-audit");
|
||||
log.info("HONOR cancel-audit called for appId={}", appId);
|
||||
}
|
||||
|
||||
private void cancelAtOppo(AppVersionEntity v, Map<String, String> creds) throws Exception {
|
||||
String clientId = require(creds, "clientId", "OPPO");
|
||||
String clientSecret = require(creds, "clientSecret", "OPPO");
|
||||
String packageName = requirePackageName(v);
|
||||
String token = oppoGetToken(clientId, clientSecret);
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("pkg_name", packageName);
|
||||
params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode())));
|
||||
String url = oppoRequestUrl(
|
||||
"https://oop-openapi-cn.heytapmobi.com/developer/v1/app/cancel-audit-info",
|
||||
params, token, true, clientSecret);
|
||||
ResponseEntity<String> resp = rest.getForEntity(url, String.class);
|
||||
JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody()));
|
||||
oppoCheckSuccess(root, "cancel-audit");
|
||||
log.info("OPPO cancel-audit called for pkg={}", packageName);
|
||||
}
|
||||
|
||||
private void cancelAtVivo(AppVersionEntity v, Map<String, String> creds) throws Exception {
|
||||
String accessKey = require(creds, "accessKey", "VIVO");
|
||||
String accessSecret = require(creds, "accessSecret", "VIVO");
|
||||
String packageName = requirePackageName(v);
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("packageName", packageName);
|
||||
String url = vivoRequestUrl(accessKey, accessSecret, "app.sync.withdraw.app", params);
|
||||
ResponseEntity<String> resp = rest.getForEntity(url, String.class);
|
||||
JsonNode root = mapper.readTree(Objects.requireNonNull(resp.getBody()));
|
||||
vivoCheckSuccess(root, "撤回");
|
||||
log.info("VIVO withdraw called for pkg={}", packageName);
|
||||
}
|
||||
|
||||
// ── Modify publish schedule ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Change when the approved version goes live.
|
||||
* publishType: "IMMEDIATE" or "SCHEDULED"
|
||||
* scheduledAt: required when publishType == "SCHEDULED"
|
||||
*/
|
||||
public AppVersionEntity updatePublishSchedule(String versionId, String publishType,
|
||||
java.time.LocalDateTime scheduledAt) {
|
||||
AppVersionEntity v = versionRepo.findById(versionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionId));
|
||||
if ("IMMEDIATE".equalsIgnoreCase(publishType)) {
|
||||
v.setScheduledPublishAt(null);
|
||||
} else if ("SCHEDULED".equalsIgnoreCase(publishType)) {
|
||||
if (scheduledAt == null) throw new IllegalArgumentException("scheduledAt required for SCHEDULED publish");
|
||||
v.setScheduledPublishAt(scheduledAt);
|
||||
}
|
||||
// Try to call HUAWEI API to update release plan if token available
|
||||
try {
|
||||
AppStoreConfigEntity cfg = configRepo
|
||||
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.HUAWEI)
|
||||
.orElse(null);
|
||||
if (cfg != null && cfg.isEnabled()) {
|
||||
Map<String, String> creds = parseConfig(cfg.getConfigJson());
|
||||
huaweiUpdateReleasePlan(v, creds, publishType, scheduledAt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("HUAWEI release plan update failed (non-fatal): {}", e.getMessage());
|
||||
}
|
||||
return versionRepo.save(v);
|
||||
}
|
||||
|
||||
private void huaweiUpdateReleasePlan(AppVersionEntity v, Map<String, String> creds,
|
||||
String publishType, java.time.LocalDateTime scheduledAt) throws Exception {
|
||||
String clientId = require(creds, "clientId", "HUAWEI");
|
||||
String clientSecret = require(creds, "clientSecret", "HUAWEI");
|
||||
String token = huaweiGetToken(clientId, clientSecret);
|
||||
String hwAppId = huaweiGetAppId(clientId, token, v.getPackageName());
|
||||
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if ("IMMEDIATE".equalsIgnoreCase(publishType)) {
|
||||
body.put("releaseType", 1);
|
||||
} else {
|
||||
body.put("releaseType", 3);
|
||||
body.put("releaseTime", scheduledAt.format(
|
||||
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
}
|
||||
rest.exchange(HUAWEI_API + "/api/publish/v2/app-info?appId=" + hwAppId,
|
||||
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||
log.info("HUAWEI release plan updated: type={} for appId={}", publishType, hwAppId);
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
private JsonNode miGetAppInfo(String account,
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户