From 4136cd57b6e8eab3cf11ed00478f538990671819 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sat, 16 May 2026 15:34:59 +0800 Subject: [PATCH] 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 --- .../xuqm/tenant/controller/OpsController.java | 13 ++ .../com/xuqm/tenant/service/OpsService.java | 9 ++ .../xuqm/update/service/AppStoreService.java | 63 +++++++--- .../service/StoreSubmissionService.java | 112 ++++++++++-------- 4 files changed, 133 insertions(+), 64 deletions(-) diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java index 6f54e5c..1726a79 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/OpsController.java @@ -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> transferApp( + @PathVariable String appKey, + @RequestBody Map 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>> listOperationLogs( diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java index 10d0a98..4741591 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/OpsService.java @@ -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(); diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index 8fd3b3c..ebd72d1 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -407,19 +407,60 @@ public class AppStoreService { // ── Webhook delivery ───────────────────────────────────────────────────── private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) { + Map cfg = Map.of(); String url = v.getWebhookUrl(); if (url == null || url.isBlank()) { try { - Map 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 ────────────────────────────────────────────────────────────── diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index cfe2d67..7869e6f 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -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,52 +197,29 @@ public class StoreSubmissionService { } } } - List> futures = plans.stream() - .map(plan -> CompletableFuture.runAsync(() -> { - try { - submitToStore(plan.storeType, v, apkFile, plan.creds); - storeService.updateStoreReview(versionId, plan.storeType, - AppVersionEntity.StoreReviewState.UNDER_REVIEW); - successCount.incrementAndGet(); - recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of( - "durationMs", System.currentTimeMillis() - plan.storeStartedAt, - "reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name() - ), null); - log.info("Submitted version {} to {}", versionId, plan.storeType); - } catch (Exception e) { - rejectedCount.incrementAndGet(); - String message = describeException(e); - log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), e); - try { - recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( - "durationMs", System.currentTimeMillis() - plan.storeStartedAt, - "phase", "SUBMISSION", - "errorClass", e.getClass().getName(), - "reason", message - ), message); - } catch (Exception logEx) { - log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage()); - } - try { - storeService.updateStoreReview(versionId, plan.storeType, - AppVersionEntity.StoreReviewState.REJECTED, - message.length() > 500 ? message.substring(0, 500) : message); - } catch (Exception ex) { - log.warn("Failed to persist rejection for {}/{} batchId={}: {}", - 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()); + 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> futures = plans.stream() + .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( @@ -262,6 +242,44 @@ public class StoreSubmissionService { } } + 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, + AppVersionEntity.StoreReviewState.UNDER_REVIEW); + successCount.incrementAndGet(); + recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of( + "durationMs", System.currentTimeMillis() - plan.storeStartedAt, + "reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name() + ), null); + log.info("Submitted version {} to {}", versionId, plan.storeType); + } catch (Exception e) { + rejectedCount.incrementAndGet(); + String message = describeException(e); + log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), e); + try { + recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( + "durationMs", System.currentTimeMillis() - plan.storeStartedAt, + "phase", "SUBMISSION", + "errorClass", e.getClass().getName(), + "reason", message + ), message); + } catch (Exception logEx) { + log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage()); + } + try { + storeService.updateStoreReview(versionId, plan.storeType, + AppVersionEntity.StoreReviewState.REJECTED, + message.length() > 500 ? message.substring(0, 500) : message); + } catch (Exception ex) { + log.warn("Failed to persist rejection for {}/{} batchId={}: {}", + v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex); + } + } + } + // ── Dispatch ───────────────────────────────────────────────────────────── private void submitToStore(String storeType, AppVersionEntity v, File file,