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))); 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,52 +197,29 @@ public class StoreSubmissionService {
} }
} }
} }
List<CompletableFuture<Void>> futures = plans.stream() boolean parallel = publishConfigService.getConfigNode(v.getAppKey())
.map(plan -> CompletableFuture.runAsync(() -> { .path("parallelStoreUpload").asBoolean(true);
try { log.info("Store submit mode: {} version={} batchId={}", parallel ? "PARALLEL" : "SEQUENTIAL", versionId, batchId);
submitToStore(plan.storeType, v, apkFile, plan.creds);
storeService.updateStoreReview(versionId, plan.storeType, if (parallel) {
AppVersionEntity.StoreReviewState.UNDER_REVIEW); List<CompletableFuture<Void>> futures = plans.stream()
successCount.incrementAndGet(); .map(plan -> CompletableFuture.runAsync(() ->
recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of( executeSinglePlan(plan, v, apkFile, versionId, batchId, successCount, rejectedCount)))
"durationMs", System.currentTimeMillis() - plan.storeStartedAt, .toList();
"reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name() if (!futures.isEmpty()) {
), null); try {
log.info("Submitted version {} to {}", versionId, plan.storeType); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
} catch (Exception e) { .get(120, java.util.concurrent.TimeUnit.MINUTES);
rejectedCount.incrementAndGet(); } catch (java.util.concurrent.TimeoutException te) {
String message = describeException(e); log.error("Store submit batch timed out after 120 minutes for version={}", versionId);
log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), e); futures.forEach(f -> f.cancel(true));
try { } catch (Exception e) {
recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( log.error("Store submit batch wait error for version={}: {}", versionId, e.getMessage());
"durationMs", System.currentTimeMillis() - plan.storeStartedAt, }
"phase", "SUBMISSION", }
"errorClass", e.getClass().getName(), } else {
"reason", message for (SubmissionPlan plan : plans) {
), message); executeSinglePlan(plan, v, apkFile, versionId, batchId, successCount, rejectedCount);
} 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());
} }
} }
recordBatchEvent(v, versionId, batchId, "STORE_SUBMIT_BATCH_END", startedAt, Map.of( 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 // Dispatch
private void submitToStore(String storeType, AppVersionEntity v, File file, private void submitToStore(String storeType, AppVersionEntity v, File file,