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>
这个提交包含在:
XuqmGroup 2026-05-16 15:34:59 +08:00
父节点 57ad8f7f25
当前提交 4136cd57b6
共有 4 个文件被更改,包括 133 次插入64 次删除

查看文件

@ -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