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)));
|
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")
|
@GetMapping("/api/ops/operation-logs")
|
||||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listOperationLogs(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> listOperationLogs(
|
||||||
|
|||||||
@ -254,6 +254,15 @@ public class OpsService {
|
|||||||
return operationLogRepository.findAllByOrderByCreatedAtDesc(pageable);
|
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) {
|
public void initDefaultAdmin(String username, String rawPassword) {
|
||||||
if (opsAdminRepository.findByUsername(username).isPresent()) return;
|
if (opsAdminRepository.findByUsername(username).isPresent()) return;
|
||||||
OpsAdminEntity admin = new OpsAdminEntity();
|
OpsAdminEntity admin = new OpsAdminEntity();
|
||||||
|
|||||||
@ -407,19 +407,60 @@ public class AppStoreService {
|
|||||||
// ── Webhook delivery ─────────────────────────────────────────────────────
|
// ── Webhook delivery ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) {
|
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) {
|
||||||
|
Map<String, String> cfg = Map.of();
|
||||||
String url = v.getWebhookUrl();
|
String url = v.getWebhookUrl();
|
||||||
if (url == null || url.isBlank()) {
|
if (url == null || url.isBlank()) {
|
||||||
try {
|
try {
|
||||||
Map<String, String> shared = getReviewWebhookConfig(v.getAppKey());
|
cfg = getReviewWebhookConfig(v.getAppKey());
|
||||||
url = shared.get("webhookUrl");
|
url = cfg.get("webhookUrl");
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
url = null;
|
url = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (url == null || url.isBlank()) return;
|
if (url == null || url.isBlank()) return;
|
||||||
|
|
||||||
|
String notifyType = cfg.getOrDefault("notifyType", "CUSTOM");
|
||||||
try {
|
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",
|
"event", "store_review_update",
|
||||||
"versionId", v.getId(),
|
"versionId", v.getId(),
|
||||||
"appKey", v.getAppKey(),
|
"appKey", v.getAppKey(),
|
||||||
@ -428,20 +469,8 @@ public class AppStoreService {
|
|||||||
"reviewState", state.name(),
|
"reviewState", state.name(),
|
||||||
"reviewReason", reason == null ? "" : reason,
|
"reviewReason", reason == null ? "" : reason,
|
||||||
"publishStatus", v.getPublishStatus().name(),
|
"publishStatus", v.getPublishStatus().name(),
|
||||||
"timestamp", System.currentTimeMillis()
|
"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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -65,6 +65,7 @@ public class StoreSubmissionService {
|
|||||||
private final AppStoreService storeService;
|
private final AppStoreService storeService;
|
||||||
private final UpdateAssetService updateAssetService;
|
private final UpdateAssetService updateAssetService;
|
||||||
private final UpdateOperationLogService operationLogService;
|
private final UpdateOperationLogService operationLogService;
|
||||||
|
private final PublishConfigService publishConfigService;
|
||||||
|
|
||||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||||
private String uploadDir;
|
private String uploadDir;
|
||||||
@ -73,12 +74,14 @@ public class StoreSubmissionService {
|
|||||||
AppStoreConfigRepository configRepo,
|
AppStoreConfigRepository configRepo,
|
||||||
AppStoreService storeService,
|
AppStoreService storeService,
|
||||||
UpdateAssetService updateAssetService,
|
UpdateAssetService updateAssetService,
|
||||||
UpdateOperationLogService operationLogService) {
|
UpdateOperationLogService operationLogService,
|
||||||
|
PublishConfigService publishConfigService) {
|
||||||
this.versionRepo = versionRepo;
|
this.versionRepo = versionRepo;
|
||||||
this.configRepo = configRepo;
|
this.configRepo = configRepo;
|
||||||
this.storeService = storeService;
|
this.storeService = storeService;
|
||||||
this.updateAssetService = updateAssetService;
|
this.updateAssetService = updateAssetService;
|
||||||
this.operationLogService = operationLogService;
|
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()
|
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 {
|
try {
|
||||||
submitToStore(plan.storeType, v, apkFile, plan.creds);
|
submitToStore(plan.storeType, v, apkFile, plan.creds);
|
||||||
storeService.updateStoreReview(versionId, plan.storeType,
|
storeService.updateStoreReview(versionId, plan.storeType,
|
||||||
@ -229,37 +278,6 @@ public class StoreSubmissionService {
|
|||||||
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
|
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 ─────────────────────────────────────────────────────────────
|
// ── Dispatch ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户