feat: add app transfer API, parallel store upload, DingTalk/WeCom/Feishu webhook formats
- OpsController/OpsService: POST /api/ops/apps/{appKey}/transfer to move app between tenants
- StoreSubmissionService: read parallelStoreUpload from publish config; conditional parallel vs sequential submission
- AppStoreService: support DINGTALK/WECOM/FEISHU/CUSTOM notify formats in sendWebhook()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
57ad8f7f25
当前提交
4136cd57b6
@ -158,6 +158,19 @@ public class OpsController {
|
||||
return ResponseEntity.ok(ApiResponse.success(opsService.listAppServices(appKey)));
|
||||
}
|
||||
|
||||
@PostMapping("/api/ops/apps/{appKey}/transfer")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Void>> transferApp(
|
||||
@PathVariable String appKey,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String targetTenantId = body.get("targetTenantId");
|
||||
if (targetTenantId == null || targetTenantId.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.badRequest("targetTenantId is required"));
|
||||
}
|
||||
opsService.transferApp(appKey, targetTenantId);
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@GetMapping("/api/ops/operation-logs")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listOperationLogs(
|
||||
|
||||
@ -254,6 +254,15 @@ public class OpsService {
|
||||
return operationLogRepository.findAllByOrderByCreatedAtDesc(pageable);
|
||||
}
|
||||
|
||||
public void transferApp(String appKey, String targetTenantId) {
|
||||
AppEntity app = appRepository.findByAppKey(appKey)
|
||||
.orElseThrow(() -> new IllegalArgumentException("应用不存在"));
|
||||
tenantRepository.findById(targetTenantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("目标租户不存在"));
|
||||
app.setTenantId(targetTenantId);
|
||||
appRepository.save(app);
|
||||
}
|
||||
|
||||
public void initDefaultAdmin(String username, String rawPassword) {
|
||||
if (opsAdminRepository.findByUsername(username).isPresent()) return;
|
||||
OpsAdminEntity admin = new OpsAdminEntity();
|
||||
|
||||
@ -407,19 +407,60 @@ public class AppStoreService {
|
||||
// ── Webhook delivery ─────────────────────────────────────────────────────
|
||||
|
||||
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) {
|
||||
Map<String, String> cfg = Map.of();
|
||||
String url = v.getWebhookUrl();
|
||||
if (url == null || url.isBlank()) {
|
||||
try {
|
||||
Map<String, String> shared = getReviewWebhookConfig(v.getAppKey());
|
||||
url = shared.get("webhookUrl");
|
||||
cfg = getReviewWebhookConfig(v.getAppKey());
|
||||
url = cfg.get("webhookUrl");
|
||||
} catch (Exception ignored) {
|
||||
url = null;
|
||||
}
|
||||
}
|
||||
if (url == null || url.isBlank()) return;
|
||||
|
||||
String notifyType = cfg.getOrDefault("notifyType", "CUSTOM");
|
||||
try {
|
||||
String body = mapper.writeValueAsString(Map.of(
|
||||
String body = buildWebhookBody(notifyType, v, storeType, state, reason);
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json");
|
||||
if ("CUSTOM".equalsIgnoreCase(notifyType)) {
|
||||
String secret = resolveWebhookSecret(v.getAppKey());
|
||||
req.header("X-Xuqm-Webhook-Secret", secret == null ? "" : secret);
|
||||
}
|
||||
http.sendAsync(req.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
|
||||
HttpResponse.BodyHandlers.discarding())
|
||||
.exceptionally(e -> { log.warn("Webhook delivery failed: {}", e.getMessage()); return null; });
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build webhook payload: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildWebhookBody(String notifyType, AppVersionEntity v,
|
||||
String storeType, AppVersionEntity.StoreReviewState state, String reason) throws Exception {
|
||||
String stateLabel = switch (state) {
|
||||
case UNDER_REVIEW -> "审核中";
|
||||
case APPROVED -> "已通过";
|
||||
case REJECTED -> "已拒绝";
|
||||
default -> state.name();
|
||||
};
|
||||
String storeLabel = storeType;
|
||||
String text = String.format("【应用审核通知】%s - %s %s%s",
|
||||
v.getVersionName(), storeLabel, stateLabel,
|
||||
(reason != null && !reason.isBlank()) ? ":" + reason : "");
|
||||
|
||||
return switch (notifyType.toUpperCase()) {
|
||||
case "DINGTALK" -> mapper.writeValueAsString(Map.of(
|
||||
"msgtype", "text",
|
||||
"text", Map.of("content", text)));
|
||||
case "WECOM" -> mapper.writeValueAsString(Map.of(
|
||||
"msgtype", "text",
|
||||
"text", Map.of("content", text)));
|
||||
case "FEISHU" -> mapper.writeValueAsString(Map.of(
|
||||
"msg_type", "text",
|
||||
"content", Map.of("text", text)));
|
||||
default -> mapper.writeValueAsString(Map.of(
|
||||
"event", "store_review_update",
|
||||
"versionId", v.getId(),
|
||||
"appKey", v.getAppKey(),
|
||||
@ -428,20 +469,8 @@ public class AppStoreService {
|
||||
"reviewState", state.name(),
|
||||
"reviewReason", reason == null ? "" : reason,
|
||||
"publishStatus", v.getPublishStatus().name(),
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
String secret = resolveWebhookSecret(v.getAppKey());
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Xuqm-Webhook-Secret", secret == null ? "" : secret)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
|
||||
.exceptionally(e -> { log.warn("Webhook delivery failed: {}", e.getMessage()); return null; });
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build webhook payload: {}", e.getMessage());
|
||||
}
|
||||
"timestamp", System.currentTimeMillis()));
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -65,6 +65,7 @@ public class StoreSubmissionService {
|
||||
private final AppStoreService storeService;
|
||||
private final UpdateAssetService updateAssetService;
|
||||
private final UpdateOperationLogService operationLogService;
|
||||
private final PublishConfigService publishConfigService;
|
||||
|
||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||
private String uploadDir;
|
||||
@ -73,12 +74,14 @@ public class StoreSubmissionService {
|
||||
AppStoreConfigRepository configRepo,
|
||||
AppStoreService storeService,
|
||||
UpdateAssetService updateAssetService,
|
||||
UpdateOperationLogService operationLogService) {
|
||||
UpdateOperationLogService operationLogService,
|
||||
PublishConfigService publishConfigService) {
|
||||
this.versionRepo = versionRepo;
|
||||
this.configRepo = configRepo;
|
||||
this.storeService = storeService;
|
||||
this.updateAssetService = updateAssetService;
|
||||
this.operationLogService = operationLogService;
|
||||
this.publishConfigService = publishConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -194,8 +197,54 @@ public class StoreSubmissionService {
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean parallel = publishConfigService.getConfigNode(v.getAppKey())
|
||||
.path("parallelStoreUpload").asBoolean(true);
|
||||
log.info("Store submit mode: {} version={} batchId={}", parallel ? "PARALLEL" : "SEQUENTIAL", versionId, batchId);
|
||||
|
||||
if (parallel) {
|
||||
List<CompletableFuture<Void>> futures = plans.stream()
|
||||
.map(plan -> CompletableFuture.runAsync(() -> {
|
||||
.map(plan -> CompletableFuture.runAsync(() ->
|
||||
executeSinglePlan(plan, v, apkFile, versionId, batchId, successCount, rejectedCount)))
|
||||
.toList();
|
||||
if (!futures.isEmpty()) {
|
||||
try {
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.get(120, java.util.concurrent.TimeUnit.MINUTES);
|
||||
} catch (java.util.concurrent.TimeoutException te) {
|
||||
log.error("Store submit batch timed out after 120 minutes for version={}", versionId);
|
||||
futures.forEach(f -> f.cancel(true));
|
||||
} catch (Exception e) {
|
||||
log.error("Store submit batch wait error for version={}: {}", versionId, e.getMessage());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (SubmissionPlan plan : plans) {
|
||||
executeSinglePlan(plan, v, apkFile, versionId, batchId, successCount, rejectedCount);
|
||||
}
|
||||
}
|
||||
recordBatchEvent(v, versionId, batchId, "STORE_SUBMIT_BATCH_END", startedAt, Map.of(
|
||||
"targets", targets,
|
||||
"successCount", successCount.get(),
|
||||
"rejectedCount", rejectedCount.get(),
|
||||
"skippedCount", skippedCount.get(),
|
||||
"durationMs", Duration.between(startedAt, LocalDateTime.now()).toMillis()
|
||||
));
|
||||
log.info("Store submit batch end version={} appKey={} batchId={} success={} rejected={} skipped={}",
|
||||
versionId, v.getAppKey(), batchId, successCount.get(), rejectedCount.get(), skippedCount.get());
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void processScheduledStoreSubmissions() {
|
||||
List<AppVersionEntity> due = versionRepo.findByStoreSubmitModeAndStoreSubmitScheduledAtBefore(
|
||||
"SCHEDULED", java.time.LocalDateTime.now());
|
||||
for (AppVersionEntity v : due) {
|
||||
executeSubmitAsync(v.getId());
|
||||
}
|
||||
}
|
||||
|
||||
private void executeSinglePlan(SubmissionPlan plan, AppVersionEntity v, File apkFile,
|
||||
String versionId, String batchId,
|
||||
AtomicInteger successCount, AtomicInteger rejectedCount) {
|
||||
try {
|
||||
submitToStore(plan.storeType, v, apkFile, plan.creds);
|
||||
storeService.updateStoreReview(versionId, plan.storeType,
|
||||
@ -229,37 +278,6 @@ public class StoreSubmissionService {
|
||||
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
}))
|
||||
.toList();
|
||||
if (!futures.isEmpty()) {
|
||||
try {
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||
.get(120, java.util.concurrent.TimeUnit.MINUTES);
|
||||
} catch (java.util.concurrent.TimeoutException te) {
|
||||
log.error("Store submit batch timed out after 120 minutes for version={}", versionId);
|
||||
futures.forEach(f -> f.cancel(true));
|
||||
} catch (Exception e) {
|
||||
log.error("Store submit batch wait error for version={}: {}", versionId, e.getMessage());
|
||||
}
|
||||
}
|
||||
recordBatchEvent(v, versionId, batchId, "STORE_SUBMIT_BATCH_END", startedAt, Map.of(
|
||||
"targets", targets,
|
||||
"successCount", successCount.get(),
|
||||
"rejectedCount", rejectedCount.get(),
|
||||
"skippedCount", skippedCount.get(),
|
||||
"durationMs", Duration.between(startedAt, LocalDateTime.now()).toMillis()
|
||||
));
|
||||
log.info("Store submit batch end version={} appKey={} batchId={} success={} rejected={} skipped={}",
|
||||
versionId, v.getAppKey(), batchId, successCount.get(), rejectedCount.get(), skippedCount.get());
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void processScheduledStoreSubmissions() {
|
||||
List<AppVersionEntity> due = versionRepo.findByStoreSubmitModeAndStoreSubmitScheduledAtBefore(
|
||||
"SCHEDULED", java.time.LocalDateTime.now());
|
||||
for (AppVersionEntity v : due) {
|
||||
executeSubmitAsync(v.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户