From 93311f17390bc0ebccb71f8602797506b2341b17 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 19:08:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20=E6=B7=BB=E5=8A=A0=E9=B8=BF?= =?UTF-8?q?=E8=92=99SDK=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现SDKContext用于配置管理和数据持久化存储 - 定义完整的类型系统包括消息、用户、群组等接口 - 集成更新SDK支持原生应用和RN热更新检查 - 提供统一的XuqmSDK入口类和模块导出 - 编写详细的开发文档和使用示例 --- docs/API_ACCESS.md | 10 +- .../update/controller/AppStoreController.java | 42 +- .../controller/AppVersionController.java | 139 +++++- .../controller/PublishConfigController.java | 74 ++++ .../update/controller/RnBundleController.java | 105 ++++- .../controller/UnifiedReleaseController.java | 7 +- .../update/entity/AppGrayMemberEntity.java | 57 +++ .../update/entity/AppPublishConfigEntity.java | 39 ++ .../update/entity/AppStoreConfigEntity.java | 10 +- .../xuqm/update/entity/AppVersionEntity.java | 25 ++ .../xuqm/update/entity/RnBundleEntity.java | 23 + .../repository/AppGrayMemberRepository.java | 10 + .../AppPublishConfigRepository.java | 10 + .../repository/AppVersionRepository.java | 3 + .../update/repository/RnBundleRepository.java | 4 + .../xuqm/update/service/AppStoreService.java | 88 +++- .../ConnectivityValidationService.java | 49 +++ .../update/service/PublishConfigService.java | 314 +++++++++++++ .../service/StoreSubmissionService.java | 413 +++++++++++++++++- .../update/service/UpdateAssetService.java | 82 +++- 20 files changed, 1459 insertions(+), 45 deletions(-) create mode 100644 update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/AppPublishConfigEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/AppPublishConfigRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/ConnectivityValidationService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index fbfe374..4a28f59 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -144,7 +144,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | -| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android / iOS 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,Harmony 版本仅保存市场链接,不提供本地安装包下载 | +| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,iOS / Harmony 仅记录版本号与市场跳转信息,`marketUrl` 可选,不要求本地安装包;可附带 `expectedPackageName` 作为当前应用包名守卫 | | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | @@ -163,13 +163,17 @@ - 这里的 `appId` 按 `appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。 - `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 +- 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。 - `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 - 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload` 和 `POST /api/v1/updates/app/inspect`。 - 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。 +- RN bundle 建议打成 zip 后再上传,zip 内至少包含 `rn-manifest.json`、bundle 文件和资源文件;update-service 会优先从 manifest 自动读取 `moduleId`、`version` / `bundleVersion`、`minCommonVersion` 和 `packageName`。 +- 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。 +- 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。 ## update-sdk 自动发版 -三个移动端 SDK 现在都支持通过脚本完成“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”的流程。 +Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”完成完整发布流程;iOS / Harmony 版本只记录版本号、市场跳转页和发布信息,不要求上传安装包。 脚本支持的公共参数: @@ -198,7 +202,7 @@ 5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 -租户平台里的“发版默认配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 +租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 ## curl 示例 diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java index 2675efb..248c393 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java @@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.service.AppStoreService; +import com.xuqm.update.service.ConnectivityValidationService; import com.xuqm.update.service.StoreSubmissionService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,10 +25,14 @@ public class AppStoreController { private final AppStoreService storeService; private final StoreSubmissionService submissionService; + private final ConnectivityValidationService connectivityValidationService; - public AppStoreController(AppStoreService storeService, StoreSubmissionService submissionService) { + public AppStoreController(AppStoreService storeService, + StoreSubmissionService submissionService, + ConnectivityValidationService connectivityValidationService) { this.storeService = storeService; this.submissionService = submissionService; + this.connectivityValidationService = connectivityValidationService; } // ── Store credential config ────────────────────────────────────────────── @@ -50,6 +55,9 @@ public class AppStoreController { String configJson = body.get("configJson") instanceof String s ? s : null; boolean enabled = !Boolean.FALSE.equals(body.get("enabled")); + if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK && configJson != null && !configJson.isBlank()) { + validateReviewWebhook(configJson); + } return ResponseEntity.ok(ApiResponse.success( storeService.saveConfig(appId, storeType, configJson, enabled))); } @@ -106,9 +114,23 @@ public class AppStoreController { @SuppressWarnings("unchecked") List storeTypes = body != null ? (List) body.get("storeTypes") : null; + String submitMode = body != null ? (String) body.get("submitMode") : null; + String scheduledAtText = body != null ? (String) body.get("scheduledPublishAt") : null; + Boolean autoPublishAfterReview = body != null && body.get("autoPublishAfterReview") != null + ? Boolean.valueOf(body.get("autoPublishAfterReview").toString()) : null; + java.time.LocalDateTime scheduledAt = null; + if (scheduledAtText != null && !scheduledAtText.isBlank()) { + scheduledAt = java.time.LocalDateTime.parse(scheduledAtText); + } AppVersionEntity v = storeService.markSubmitted(versionId, - storeTypes != null ? storeTypes : List.of()); - submissionService.executeSubmitAsync(versionId); + storeTypes != null ? storeTypes : List.of(), + submitMode, + scheduledAt, + autoPublishAfterReview); + String normalizedMode = submitMode == null ? "MANUAL" : submitMode.trim().toUpperCase(); + if (!"SCHEDULED".equals(normalizedMode)) { + submissionService.executeSubmitAsync(versionId); + } return ResponseEntity.ok(ApiResponse.success(v)); } @@ -131,4 +153,18 @@ public class AppStoreController { return ResponseEntity.ok(ApiResponse.success( storeService.updateStoreReview(versionId, storeType, state))); } + + private void validateReviewWebhook(String configJson) { + try { + @SuppressWarnings("unchecked") + Map config = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(configJson, Map.class); + String webhookUrl = config.get("webhookUrl") == null ? "" : config.get("webhookUrl").toString().trim(); + if (!webhookUrl.isBlank()) { + connectivityValidationService.validateCallbackUrl(webhookUrl, "审核通知"); + } + } catch (Exception e) { + throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e); + } + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 1dff1b7..00c2a4b 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -16,6 +16,8 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; import com.xuqm.update.service.UpdateAssetService; +import com.xuqm.update.service.PublishConfigService; +import com.xuqm.update.service.AppStoreService; @RestController @RequestMapping("/api/v1/updates") @@ -25,10 +27,17 @@ public class AppVersionController { private final AppVersionRepository versionRepository; private final UpdateAssetService updateAssetService; + private final PublishConfigService publishConfigService; + private final AppStoreService appStoreService; - public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService) { + public AppVersionController(AppVersionRepository versionRepository, + UpdateAssetService updateAssetService, + PublishConfigService publishConfigService, + AppStoreService appStoreService) { this.versionRepository = versionRepository; this.updateAssetService = updateAssetService; + this.publishConfigService = publishConfigService; + this.appStoreService = appStoreService; } @GetMapping("/app/check") @@ -46,6 +55,12 @@ public class AppVersionController { } AppVersionEntity v = latest.get(); + String appStoreJumpUrl = hasText(v.getAppStoreUrl()) + ? v.getAppStoreUrl() + : appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE); + String harmonyJumpUrl = hasText(v.getMarketUrl()) + ? v.getMarketUrl() + : appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", true, "versionName", v.getVersionName(), @@ -53,8 +68,8 @@ public class AppVersionController { "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", "forceUpdate", v.isForceUpdate(), - "appStoreUrl", v.getAppStoreUrl() != null ? v.getAppStoreUrl() : "", - "marketUrl", v.getMarketUrl() != null ? v.getMarketUrl() : "" + "appStoreUrl", appStoreJumpUrl, + "marketUrl", harmonyJumpUrl ))); } @@ -74,6 +89,7 @@ public class AppVersionController { @RequestParam(defaultValue = "false") boolean autoPublishAfterReview, @RequestParam(defaultValue = "false") boolean publishImmediately, @RequestParam(required = false) String packageName, + @RequestParam(required = false) String expectedPackageName, @RequestParam(required = false) String appStoreUrl, @RequestParam(required = false) String marketUrl) throws Exception { @@ -92,29 +108,29 @@ public class AppVersionController { if (!hasText(resolvedVersionName) || resolvedVersionCode == null) { throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package"); } - if (platform != AppVersionEntity.Platform.HARMONY && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) { - throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID and IOS releases"); + if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) { + throw new IllegalArgumentException("packageName does not match current app packageName"); } - if (platform == AppVersionEntity.Platform.HARMONY && !hasText(marketUrl)) { - throw new IllegalArgumentException("marketUrl is required for HARMONY releases"); + if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) { + throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases"); } - if (platform == AppVersionEntity.Platform.HARMONY && !hasText(resolvedPackageName)) { - throw new IllegalArgumentException("packageName is required for HARMONY releases"); - } - AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); entity.setPlatform(platform); entity.setVersionName(resolvedVersionName); entity.setVersionCode(resolvedVersionCode); - entity.setDownloadUrl(platform == AppVersionEntity.Platform.HARMONY - ? null - : (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))); + entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID + ? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile)) + : null); entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); + entity.setStoreSubmitMode("MANUAL"); + entity.setStoreSubmitScheduledAt(null); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); } @@ -122,8 +138,16 @@ public class AppVersionController { entity.setStoreSubmitTargets(storeSubmitTargets); entity.setAutoPublishAfterReview(autoPublishAfterReview); entity.setPackageName(resolvedPackageName); - entity.setAppStoreUrl(appStoreUrl); - entity.setMarketUrl(marketUrl); + entity.setAppStoreUrl(hasText(appStoreUrl) + ? appStoreUrl + : (platform == AppVersionEntity.Platform.IOS + ? appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE) + : null)); + entity.setMarketUrl(hasText(marketUrl) + ? marketUrl + : (platform == AppVersionEntity.Platform.HARMONY + ? appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP) + : null)); if (publishImmediately) { entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setGrayEnabled(false); @@ -143,11 +167,29 @@ public class AppVersionController { } @PostMapping("/app/{id}/publish") - public ResponseEntity> publish(@PathVariable String id) { + public ResponseEntity> publish( + @PathVariable String id, + @RequestBody(required = false) Map body) { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); - entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately")); + String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null + ? body.get("scheduledPublishAt").toString() : null; + boolean forceUpdate = body != null && body.get("forceUpdate") != null + ? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate(); + entity.setForceUpdate(forceUpdate); + if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { + entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + entity.setScheduledPublishAt(null); + } else { + entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); + if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { + entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); + } + } entity.setGrayEnabled(false); entity.setGrayPercent(0); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); } @@ -161,10 +203,30 @@ public class AppVersionController { @PostMapping("/app/{id}/gray") public ResponseEntity> gray( @PathVariable String id, - @RequestBody Map body) { + @RequestBody Map body) throws Exception { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); - entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled"))); - entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + boolean enabled = Boolean.TRUE.equals(body.get("enabled")); + String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase(); + entity.setGrayEnabled(enabled); + if (!enabled) { + entity.setGrayPercent(0); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); + } else if ("MEMBERS".equals(grayMode)) { + List memberIds = extractMemberIds(body.get("memberIds")); + String selectionSource = body.get("selectionSource") == null ? "LOCAL" + : body.get("selectionSource").toString().trim().toUpperCase(); + if (memberIds.isEmpty() && "CALLBACK".equals(selectionSource)) { + memberIds = publishConfigService.resolveGrayMembers(entity.getAppId(), body); + } + entity.setGrayMode("MEMBERS"); + entity.setGrayMemberIds(toJson(memberIds)); + entity.setGrayPercent(0); + } else { + entity.setGrayMode("PERCENT"); + entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + entity.setGrayMemberIds(null); + } entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); } @@ -179,4 +241,39 @@ public class AppVersionController { private boolean hasText(String value) { return value != null && !value.isBlank(); } + + private List extractMemberIds(Object raw) { + if (raw == null) { + return List.of(); + } + if (raw instanceof List list) { + return list.stream() + .map(String::valueOf) + .filter(this::hasText) + .map(String::trim) + .toList(); + } + if (raw instanceof String s) { + if (!hasText(s)) { + return List.of(); + } + try { + return java.util.Arrays.stream(s.split(",")) + .map(String::trim) + .filter(this::hasText) + .toList(); + } catch (Exception ignored) { + return List.of(); + } + } + return List.of(); + } + + private String toJson(List values) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values); + } catch (Exception e) { + return "[]"; + } + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java b/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java new file mode 100644 index 0000000..82b6f0e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java @@ -0,0 +1,74 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.AppPublishConfigEntity; +import com.xuqm.update.service.ConnectivityValidationService; +import com.xuqm.update.service.PublishConfigService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/updates") +public class PublishConfigController { + + private final PublishConfigService publishConfigService; + private final ConnectivityValidationService connectivityValidationService; + + public PublishConfigController(PublishConfigService publishConfigService, + ConnectivityValidationService connectivityValidationService) { + this.publishConfigService = publishConfigService; + this.connectivityValidationService = connectivityValidationService; + } + + @GetMapping("/publish/config") + public ResponseEntity> getConfig(@RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(publishConfigService.getConfig(appId))); + } + + @PutMapping("/publish/config") + public ResponseEntity> saveConfig( + @RequestParam String appId, + @RequestBody Map body) { + validateCallbacks(body); + return ResponseEntity.ok(ApiResponse.success(publishConfigService.saveConfig(appId, body))); + } + + @GetMapping("/gray/members") + public ResponseEntity>> listMembers( + @RequestParam String appId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String groupName) { + return ResponseEntity.ok(ApiResponse.success( + publishConfigService.listGrayMembers(appId, keyword, groupName))); + } + + @PostMapping("/gray/members/sync") + public ResponseEntity>> syncMembers( + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appId))); + } + + @PostMapping("/gray/members/import") + public ResponseEntity>> importMembers( + @RequestParam String appId, + @RequestBody String payload) { + return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appId, payload))); + } + + private void validateCallbacks(Map body) { + if (body == null) { + return; + } + String selectCallback = body.get("graySelectCallbackUrl") == null ? "" : body.get("graySelectCallbackUrl").toString().trim(); + String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim(); + if (!selectCallback.isBlank()) { + connectivityValidationService.validateCallbackUrl(selectCallback, "灰度成员选择回调"); + } + if (!syncCallback.isBlank()) { + connectivityValidationService.validateCallbackUrl(syncCallback, "灰度成员同步回调"); + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index 3ca6c21..43a1be5 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.repository.RnBundleRepository; +import com.xuqm.update.service.PublishConfigService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,13 +23,17 @@ public class RnBundleController { private final RnBundleRepository bundleRepository; private final UpdateAssetService updateAssetService; + private final PublishConfigService publishConfigService; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; - public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) { + public RnBundleController(RnBundleRepository bundleRepository, + UpdateAssetService updateAssetService, + PublishConfigService publishConfigService) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; + this.publishConfigService = publishConfigService; } @GetMapping("/update/check") @@ -52,6 +57,7 @@ public class RnBundleController { boolean needsUpdate = !b.getVersion().equals(currentVersion); return ResponseEntity.ok(ApiResponse.success(Map.of( "needsUpdate", needsUpdate, + "bundleVersion", parseBundleVersion(b.getVersion()), "latestVersion", b.getVersion(), "downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId, "md5", b.getMd5(), @@ -97,6 +103,10 @@ public class RnBundleController { entity.setPackageName(resolvedPackageName); entity.setNote(note); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); + entity.setPublishMode("MANUAL"); + entity.setScheduledPublishAt(null); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); entity.setCreatedAt(LocalDateTime.now()); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } @@ -124,11 +134,28 @@ public class RnBundleController { } @PostMapping("/{id}/publish") - public ResponseEntity> publish(@PathVariable String id) { + public ResponseEntity> publish( + @PathVariable String id, + @RequestBody(required = false) Map body) { RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); - entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately")); + String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null + ? body.get("scheduledPublishAt").toString() : null; + entity.setPublishMode(publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) + ? "NOW" : "SCHEDULED"); + if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { + entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + entity.setScheduledPublishAt(null); + } else { + entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); + if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { + entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); + } + } entity.setGrayEnabled(false); entity.setGrayPercent(0); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } @@ -142,10 +169,30 @@ public class RnBundleController { @PostMapping("/{id}/gray") public ResponseEntity> gray( @PathVariable String id, - @RequestBody Map body) { + @RequestBody Map body) throws Exception { RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); - entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled"))); - entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + boolean enabled = Boolean.TRUE.equals(body.get("enabled")); + String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase(); + entity.setGrayEnabled(enabled); + if (!enabled) { + entity.setGrayPercent(0); + entity.setGrayMode("PERCENT"); + entity.setGrayMemberIds(null); + } else if ("MEMBERS".equals(grayMode)) { + List memberIds = extractMemberIds(body.get("memberIds")); + String selectionSource = body.get("selectionSource") == null ? "LOCAL" + : body.get("selectionSource").toString().trim().toUpperCase(); + if (memberIds.isEmpty() && "CALLBACK".equals(selectionSource)) { + memberIds = publishConfigService.resolveGrayMembers(entity.getAppId(), body); + } + entity.setGrayMode("MEMBERS"); + entity.setGrayMemberIds(toJson(memberIds)); + entity.setGrayPercent(0); + } else { + entity.setGrayMode("PERCENT"); + entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + entity.setGrayMemberIds(null); + } entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } @@ -173,4 +220,50 @@ public class RnBundleController { return null; } } + + private int parseBundleVersion(String version) { + if (!hasText(version)) { + return 0; + } + try { + return Integer.parseInt(version.trim()); + } catch (Exception ignored) { + return 0; + } + } + + private String toJson(List values) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values); + } catch (Exception e) { + return "[]"; + } + } + + private List extractMemberIds(Object raw) { + if (raw == null) { + return List.of(); + } + if (raw instanceof List list) { + return list.stream() + .map(String::valueOf) + .filter(this::hasText) + .map(String::trim) + .toList(); + } + if (raw instanceof String s) { + if (!hasText(s)) { + return List.of(); + } + try { + return java.util.Arrays.stream(s.split(",")) + .map(String::trim) + .filter(this::hasText) + .toList(); + } catch (Exception ignored) { + return List.of(); + } + } + return List.of(); + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java index 22805c4..b35a825 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java @@ -59,6 +59,9 @@ public class UnifiedReleaseController { List appVersions = new ArrayList<>(); for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) { MultipartFile file = multipartRequest.getFile(item.fileKey()); + if (item.platform() == AppVersionEntity.Platform.ANDROID && file == null) { + throw new IllegalArgumentException("Android app version requires an uploaded package file"); + } AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); @@ -72,7 +75,9 @@ public class UnifiedReleaseController { entity.setMarketUrl(item.marketUrl()); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); - entity.setDownloadUrl(file != null ? updateAssetService.storeAppPackage(file) : null); + entity.setDownloadUrl(item.platform() == AppVersionEntity.Platform.ANDROID && file != null + ? updateAssetService.storeAppPackage(file) + : null); if (item.publishImmediately()) { entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setGrayEnabled(false); diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java new file mode 100644 index 0000000..5fdaf7b --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java @@ -0,0 +1,57 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_gray_member", uniqueConstraints = { + @jakarta.persistence.UniqueConstraint(columnNames = {"appId", "groupName", "userId"}) +}) +public class AppGrayMemberEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(length = 64) + private String groupName; + + @Column(nullable = false, length = 64) + private String userId; + + @Column(length = 128) + private String name; + + @Column(length = 512) + private String extraJson; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getGroupName() { return groupName; } + public void setGroupName(String groupName) { this.groupName = groupName; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getExtraJson() { return extraJson; } + public void setExtraJson(String extraJson) { this.extraJson = extraJson; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppPublishConfigEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppPublishConfigEntity.java new file mode 100644 index 0000000..963dcb1 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/AppPublishConfigEntity.java @@ -0,0 +1,39 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_publish_config", uniqueConstraints = { + @jakarta.persistence.UniqueConstraint(columnNames = {"appId"}) +}) +public class AppPublishConfigEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(columnDefinition = "TEXT") + private String configJson; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getConfigJson() { return configJson; } + public void setConfigJson(String configJson) { this.configJson = configJson; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java index 92d7cdd..9e4b514 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java @@ -13,12 +13,13 @@ public class AppStoreConfigEntity { * Supported distribution channels. * Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY * iOS: APP_STORE + * Harmony: HARMONY_APP */ public enum StoreType { - HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE; + HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE, HARMONY_APP, REVIEW_WEBHOOK; public boolean isAndroid() { - return this != APP_STORE; + return this != APP_STORE && this != HARMONY_APP && this != REVIEW_WEBHOOK; } } @@ -34,13 +35,16 @@ public class AppStoreConfigEntity { /** * Store-specific credentials stored as a flat JSON object. + * Every store config also carries its own marketUrl / jump page. * * HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."} * MI: {"username":"...","privateKey":"..."} * OPPO: {"clientId":"...","clientSecret":"..."} * VIVO: {"accessKey":"...","accessSecret":"..."} * GOOGLE_PLAY: {"serviceAccountJson":"..."} - * APP_STORE: {"teamId":"...","keyId":"...","privateKey":"...","bundleId":"..."} + * APP_STORE: {"marketUrl":"..."} + * HARMONY_APP: {"marketUrl":"..."} + * REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."} */ @Column(columnDefinition = "TEXT") private String configJson; diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index f2e8d5a..435e900 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -46,6 +46,11 @@ public class AppVersionEntity { @Column(nullable = false, length = 16) private PublishStatus publishStatus; + @Column(length = 16) + private String storeSubmitMode = "MANUAL"; + + private LocalDateTime storeSubmitScheduledAt; + @Column(length = 256) private String appStoreUrl; @@ -83,6 +88,14 @@ public class AppVersionEntity { @Column(length = 512) private String webhookUrl; + /** Gray release mode: PERCENT or MEMBERS. */ + @Column(length = 16) + private String grayMode = "PERCENT"; + + /** JSON array of gray member userIds. */ + @Column(columnDefinition = "TEXT") + private String grayMemberIds; + /** App package name / bundle identifier, e.g. com.example.myapp */ @Column(length = 256) private String packageName; @@ -117,6 +130,12 @@ public class AppVersionEntity { public PublishStatus getPublishStatus() { return publishStatus; } public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; } + public String getStoreSubmitMode() { return storeSubmitMode; } + public void setStoreSubmitMode(String storeSubmitMode) { this.storeSubmitMode = storeSubmitMode; } + + public LocalDateTime getStoreSubmitScheduledAt() { return storeSubmitScheduledAt; } + public void setStoreSubmitScheduledAt(LocalDateTime storeSubmitScheduledAt) { this.storeSubmitScheduledAt = storeSubmitScheduledAt; } + public String getAppStoreUrl() { return appStoreUrl; } public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; } @@ -149,4 +168,10 @@ public class AppVersionEntity { public String getWebhookUrl() { return webhookUrl; } public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } + + public String getGrayMode() { return grayMode; } + public void setGrayMode(String grayMode) { this.grayMode = grayMode; } + + public String getGrayMemberIds() { return grayMemberIds; } + public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } } diff --git a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java index 4c1c9c3..e897356 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java @@ -50,12 +50,23 @@ public class RnBundleEntity { @Column(nullable = false, length = 16) private PublishStatus publishStatus; + @Column(length = 16) + private String publishMode = "MANUAL"; + + private LocalDateTime scheduledPublishAt; + @Column(nullable = false) private boolean grayEnabled = false; @Column(nullable = false) private int grayPercent = 0; + @Column(length = 16) + private String grayMode = "PERCENT"; + + @Column(columnDefinition = "TEXT") + private String grayMemberIds; + @Column(nullable = false) private LocalDateTime createdAt; @@ -92,12 +103,24 @@ public class RnBundleEntity { public PublishStatus getPublishStatus() { return publishStatus; } public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; } + public String getPublishMode() { return publishMode; } + public void setPublishMode(String publishMode) { this.publishMode = publishMode; } + + public LocalDateTime getScheduledPublishAt() { return scheduledPublishAt; } + public void setScheduledPublishAt(LocalDateTime scheduledPublishAt) { this.scheduledPublishAt = scheduledPublishAt; } + public boolean isGrayEnabled() { return grayEnabled; } public void setGrayEnabled(boolean grayEnabled) { this.grayEnabled = grayEnabled; } public int getGrayPercent() { return grayPercent; } public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; } + public String getGrayMode() { return grayMode; } + public void setGrayMode(String grayMode) { this.grayMode = grayMode; } + + public String getGrayMemberIds() { return grayMemberIds; } + public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java new file mode 100644 index 0000000..6b226a3 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.AppGrayMemberEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AppGrayMemberRepository extends JpaRepository { + List findByAppIdOrderByGroupNameAscNameAscUserIdAsc(String appId); +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppPublishConfigRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppPublishConfigRepository.java new file mode 100644 index 0000000..e6ee9cc --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/AppPublishConfigRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.AppPublishConfigEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AppPublishConfigRepository extends JpaRepository { + Optional findByAppId(String appId); +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index 814b46f..45d4d5b 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -14,4 +14,7 @@ public interface AppVersionRepository extends JpaRepository findByPublishStatusAndScheduledPublishAtBefore( AppVersionEntity.PublishStatus status, LocalDateTime before); + + List findByStoreSubmitModeAndStoreSubmitScheduledAtBefore( + String storeSubmitMode, LocalDateTime before); } diff --git a/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java index 1601f16..796a6b7 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/RnBundleRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; +import java.time.LocalDateTime; public interface RnBundleRepository extends JpaRepository { List findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc( @@ -13,4 +14,7 @@ public interface RnBundleRepository extends JpaRepository findByAppIdOrderByCreatedAtDesc(String appId); Optional findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( String appId, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status); + + List findByPublishStatusAndScheduledPublishAtBefore( + RnBundleEntity.PublishStatus status, LocalDateTime before); } 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 91088a9..d10a62c 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 @@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.repository.AppStoreConfigRepository; import com.xuqm.update.repository.AppVersionRepository; +import com.xuqm.update.repository.RnBundleRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -27,10 +29,14 @@ public class AppStoreService { private final AppStoreConfigRepository configRepo; private final AppVersionRepository versionRepo; + private final RnBundleRepository rnBundleRepository; - public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo) { + public AppStoreService(AppStoreConfigRepository configRepo, + AppVersionRepository versionRepo, + RnBundleRepository rnBundleRepository) { this.configRepo = configRepo; this.versionRepo = versionRepo; + this.rnBundleRepository = rnBundleRepository; } // ── Store config CRUD ──────────────────────────────────────────────────── @@ -70,7 +76,11 @@ public class AppStoreService { * machine (it has the APK/IPA file). This endpoint records the intent and provides the * credentials the script needs. */ - public AppVersionEntity markSubmitted(String versionId, List storeTypes) throws Exception { + public AppVersionEntity markSubmitted(String versionId, + List storeTypes, + String submitMode, + LocalDateTime scheduledAt, + Boolean autoPublishAfterReview) throws Exception { AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); Map reviewMap = new LinkedHashMap<>(); @@ -79,9 +89,18 @@ public class AppStoreService { } v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + v.setStoreSubmitMode(submitMode == null || submitMode.isBlank() ? "MANUAL" : submitMode.trim().toUpperCase(Locale.ROOT)); + v.setStoreSubmitScheduledAt(scheduledAt); + if (autoPublishAfterReview != null) { + v.setAutoPublishAfterReview(autoPublishAfterReview); + } return versionRepo.save(v); } + public AppVersionEntity markSubmitted(String versionId, List storeTypes) throws Exception { + return markSubmitted(versionId, storeTypes, "MANUAL", null, null); + } + /** * Fetch enabled store credentials for use by the release script. * Returns a map of storeType -> configJson (as parsed map, not raw string). @@ -90,6 +109,9 @@ public class AppStoreService { List configs = configRepo.findByAppIdAndEnabled(appId, true); Map result = new LinkedHashMap<>(); for (AppStoreConfigEntity cfg : configs) { + if (cfg.getStoreType() == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) { + continue; + } Map parsed = cfg.getConfigJson() != null ? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {}) : Map.of(); @@ -98,6 +120,26 @@ public class AppStoreService { return result; } + public Map getReviewWebhookConfig(String appId) throws Exception { + AppStoreConfigEntity cfg = configRepo.findByAppIdAndStoreType( + appId, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null); + if (cfg == null || !cfg.isEnabled() || cfg.getConfigJson() == null || cfg.getConfigJson().isBlank()) { + return Map.of(); + } + return mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {}); + } + + public String getStoreJumpUrl(String appId, AppStoreConfigEntity.StoreType storeType) { + if (storeType == null || storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) { + return ""; + } + return configRepo.findByAppIdAndStoreType(appId, storeType) + .filter(AppStoreConfigEntity::isEnabled) + .map(AppStoreConfigEntity::getConfigJson) + .map(this::extractJumpUrl) + .orElse(""); + } + // ── Review status webhook ──────────────────────────────────────────────── /** @@ -132,15 +174,34 @@ public class AppStoreService { AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now()); for (AppVersionEntity v : due) { v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + v.setScheduledPublishAt(null); versionRepo.save(v); log.info("Scheduled publish executed for version {}", v.getId()); } + + List dueBundles = rnBundleRepository + .findByPublishStatusAndScheduledPublishAtBefore( + RnBundleEntity.PublishStatus.DRAFT, LocalDateTime.now()); + for (RnBundleEntity bundle : dueBundles) { + bundle.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); + bundle.setScheduledPublishAt(null); + rnBundleRepository.save(bundle); + log.info("Scheduled publish executed for RN bundle {}", bundle.getId()); + } } // ── Webhook delivery ───────────────────────────────────────────────────── private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) { String url = v.getWebhookUrl(); + if (url == null || url.isBlank()) { + try { + Map shared = getReviewWebhookConfig(v.getAppId()); + url = shared.get("webhookUrl"); + } catch (Exception ignored) { + url = null; + } + } if (url == null || url.isBlank()) return; try { @@ -154,9 +215,11 @@ public class AppStoreService { "publishStatus", v.getPublishStatus().name(), "timestamp", System.currentTimeMillis() )); + String secret = resolveWebhookSecret(v.getAppId()); 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()) @@ -179,4 +242,25 @@ public class AppStoreService { return targets.stream().allMatch(t -> AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t))); } + + private String resolveWebhookSecret(String appId) { + try { + return getReviewWebhookConfig(appId).getOrDefault("secret", ""); + } catch (Exception e) { + return ""; + } + } + + private String extractJumpUrl(String configJson) { + if (configJson == null || configJson.isBlank()) { + return ""; + } + try { + Map config = mapper.readValue(configJson, new TypeReference<>() {}); + Object marketUrl = config.get("marketUrl"); + return marketUrl == null ? "" : marketUrl.toString(); + } catch (Exception e) { + return ""; + } + } } diff --git a/update-service/src/main/java/com/xuqm/update/service/ConnectivityValidationService.java b/update-service/src/main/java/com/xuqm/update/service/ConnectivityValidationService.java new file mode 100644 index 0000000..b03c3ba --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/ConnectivityValidationService.java @@ -0,0 +1,49 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.*; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class ConnectivityValidationService { + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + public void validateCallbackUrl(String url, String label) { + if (url == null || url.isBlank()) { + return; + } + try { + Map body = new LinkedHashMap<>(); + body.put("probe", true); + body.put("label", label); + body.put("timestamp", System.currentTimeMillis()); + String payload = objectMapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/json") + .header("X-Xuqm-Connectivity-Check", "true") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if (status < 200 || status >= 400) { + throw new IllegalStateException("connectivity check failed with HTTP " + status); + } + } catch (Exception e) { + throw new IllegalArgumentException("cannot connect to " + label + ": " + url, e); + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java new file mode 100644 index 0000000..a1f0add --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java @@ -0,0 +1,314 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.xuqm.update.entity.AppGrayMemberEntity; +import com.xuqm.update.entity.AppPublishConfigEntity; +import com.xuqm.update.repository.AppGrayMemberRepository; +import com.xuqm.update.repository.AppPublishConfigRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class PublishConfigService { + + private static final Logger log = LoggerFactory.getLogger(PublishConfigService.class); + + private final AppPublishConfigRepository configRepository; + private final AppGrayMemberRepository grayMemberRepository; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + + public PublishConfigService(AppPublishConfigRepository configRepository, + AppGrayMemberRepository grayMemberRepository, + ObjectMapper objectMapper) { + this.configRepository = configRepository; + this.grayMemberRepository = grayMemberRepository; + this.objectMapper = objectMapper; + } + + public AppPublishConfigEntity getConfig(String appId) { + return configRepository.findByAppId(appId).orElseGet(() -> { + AppPublishConfigEntity entity = new AppPublishConfigEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setConfigJson(defaultConfigJson()); + entity.setUpdatedAt(LocalDateTime.now()); + return entity; + }); + } + + public AppPublishConfigEntity saveConfig(String appId, Map body) { + AppPublishConfigEntity entity = configRepository.findByAppId(appId).orElseGet(AppPublishConfigEntity::new); + if (entity.getId() == null) { + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + } + try { + entity.setConfigJson(objectMapper.writeValueAsString(body == null ? Map.of() : body)); + } catch (Exception e) { + throw new IllegalArgumentException("invalid publish config payload", e); + } + entity.setUpdatedAt(LocalDateTime.now()); + return configRepository.save(entity); + } + + public JsonNode getConfigNode(String appId) { + AppPublishConfigEntity entity = configRepository.findByAppId(appId).orElse(null); + if (entity == null || entity.getConfigJson() == null || entity.getConfigJson().isBlank()) { + try { + return objectMapper.readTree(defaultConfigJson()); + } catch (Exception ignored) { + return objectMapper.createObjectNode(); + } + } + try { + JsonNode node = objectMapper.readTree(entity.getConfigJson()); + return node == null ? objectMapper.createObjectNode() : node; + } catch (Exception e) { + return objectMapper.createObjectNode(); + } + } + + public List listGrayMembers(String appId, String keyword, String groupName) { + String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT); + String normalizedGroup = groupName == null ? "" : groupName.trim().toLowerCase(Locale.ROOT); + + List members = grayMemberRepository.findByAppIdOrderByGroupNameAscNameAscUserIdAsc(appId) + .stream() + .filter(item -> normalizedGroup.isBlank() + || safe(item.getGroupName()).toLowerCase(Locale.ROOT).contains(normalizedGroup)) + .filter(item -> normalizedKeyword.isBlank() + || safe(item.getUserId()).toLowerCase(Locale.ROOT).contains(normalizedKeyword) + || safe(item.getName()).toLowerCase(Locale.ROOT).contains(normalizedKeyword)) + .toList(); + + Map> grouped = new LinkedHashMap<>(); + for (AppGrayMemberEntity member : members) { + String key = safeGroupName(member.getGroupName()); + grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(new GrayMemberView( + member.getUserId(), + member.getName(), + key, + member.getExtraJson(), + member.getUpdatedAt() != null ? member.getUpdatedAt().toString() : "" + )); + } + return grouped.entrySet().stream() + .map(entry -> new GrayMemberGroupView(entry.getKey(), entry.getValue())) + .toList(); + } + + public List syncGrayMembers(String appId) { + JsonNode config = getConfigNode(appId); + String url = config.path("grayDirectorySyncCallbackUrl").asText(""); + if (url == null || url.isBlank()) { + throw new IllegalStateException("grayDirectorySyncCallbackUrl is not configured"); + } + + Map requestBody = Map.of( + "appId", appId, + "timestamp", System.currentTimeMillis(), + "action", "sync" + ); + ResponseEntity response = restTemplate.exchange( + URI.create(url), org.springframework.http.HttpMethod.POST, + new org.springframework.http.HttpEntity<>(requestBody), String.class); + return replaceGrayMembers(appId, response.getBody()); + } + + public List resolveGrayMembers(String appId, Map requestBody) { + JsonNode config = getConfigNode(appId); + String url = config.path("graySelectCallbackUrl").asText(""); + if (url == null || url.isBlank()) { + throw new IllegalStateException("graySelectCallbackUrl is not configured"); + } + Map payload = new LinkedHashMap<>(); + if (requestBody != null) { + payload.putAll(requestBody); + } + payload.put("appId", appId); + payload.put("timestamp", System.currentTimeMillis()); + ResponseEntity response = restTemplate.exchange( + URI.create(url), org.springframework.http.HttpMethod.POST, + new org.springframework.http.HttpEntity<>(payload), String.class); + return extractMemberIds(response.getBody()); + } + + public List replaceGrayMembers(String appId, String responseBody) { + List groups = parseGroups(responseBody); + if (groups == null) { + throw new IllegalStateException("Invalid gray member payload"); + } + grayMemberRepository.deleteAll( + grayMemberRepository.findByAppIdOrderByGroupNameAscNameAscUserIdAsc(appId)); + + List saved = new ArrayList<>(); + for (GrayMemberGroupPayload group : groups) { + String groupName = safeGroupName(group.groupName()); + for (GrayMemberPayload member : safeMembers(group.members())) { + if (member == null || isBlank(member.userId())) { + continue; + } + AppGrayMemberEntity entity = new AppGrayMemberEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setGroupName(groupName); + entity.setUserId(member.userId().trim()); + entity.setName(member.name() == null ? "" : member.name().trim()); + entity.setExtraJson(member.extraJson()); + entity.setUpdatedAt(LocalDateTime.now()); + saved.add(entity); + } + } + grayMemberRepository.saveAll(saved); + return listGrayMembers(appId, null, null); + } + + private List extractMemberIds(String responseBody) { + if (responseBody == null || responseBody.isBlank()) { + return List.of(); + } + try { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode memberIds = root.path("memberIds"); + if (memberIds.isArray()) { + List result = new ArrayList<>(); + for (JsonNode node : memberIds) { + String value = node.asText(""); + if (!value.isBlank()) { + result.add(value.trim()); + } + } + return result; + } + if (root.path("members").isArray()) { + List result = new ArrayList<>(); + for (JsonNode node : root.path("members")) { + String value = node.path("userId").asText(""); + if (!value.isBlank()) { + result.add(value.trim()); + } + } + return result; + } + if (root.path("groups").isArray()) { + List result = new ArrayList<>(); + for (JsonNode group : root.path("groups")) { + JsonNode members = group.path("members"); + if (!members.isArray()) { + continue; + } + for (JsonNode node : members) { + String value = node.path("userId").asText(""); + if (!value.isBlank()) { + result.add(value.trim()); + } + } + } + return result; + } + return List.of(); + } catch (Exception e) { + log.warn("Failed to parse gray member callback payload: {}", e.getMessage()); + return List.of(); + } + } + + private List parseGroups(String responseBody) { + if (responseBody == null || responseBody.isBlank()) { + return null; + } + try { + JsonNode root = objectMapper.readTree(responseBody); + if (root.path("groups").isArray()) { + List groups = new ArrayList<>(); + for (JsonNode group : root.path("groups")) { + groups.add(parseGroup(group)); + } + return groups; + } + if (root.isArray()) { + List groups = new ArrayList<>(); + for (JsonNode node : root) { + groups.add(parseGroup(node)); + } + return groups; + } + if (root.path("members").isArray()) { + return List.of(new GrayMemberGroupPayload("默认分组", parseMembers(root.path("members")))); + } + return List.of(); + } catch (Exception e) { + log.warn("Failed to parse gray member sync payload: {}", e.getMessage()); + return null; + } + } + + private GrayMemberGroupPayload parseGroup(JsonNode node) { + String groupName = node.path("groupName").asText(node.path("name").asText("默认分组")); + List members = parseMembers(node.path("members")); + return new GrayMemberGroupPayload(groupName, members); + } + + private List parseMembers(JsonNode node) { + if (node == null || !node.isArray()) { + return List.of(); + } + List members = new ArrayList<>(); + for (JsonNode item : node) { + String userId = item.path("userId").asText(""); + String name = item.path("name").asText(item.path("nickname").asText("")); + String extraJson = item.path("extraJson").isMissingNode() ? null : item.path("extraJson").toString(); + if (extraJson != null && "\"\"".equals(extraJson)) { + extraJson = null; + } + members.add(new GrayMemberPayload(userId, name, extraJson)); + } + return members; + } + + private List safeMembers(List members) { + return members == null ? List.of() : members; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String safe(String value) { + return value == null ? "" : value; + } + + private String safeGroupName(String value) { + return isBlank(value) ? "默认分组" : value.trim(); + } + + private String defaultConfigJson() { + return """ + {"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","grayDirectorySyncCallbackUrl":"","graySelectionSource":"LOCAL"} + """.trim(); + } + + public record GrayMemberGroupView(String groupName, List members) {} + public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {} + private record GrayMemberGroupPayload(String groupName, List members) {} + private record GrayMemberPayload(String userId, String name, String extraJson) {} +} 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 fd1832b..043b377 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 @@ -1,6 +1,7 @@ package com.xuqm.update.service; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppVersionEntity; @@ -12,14 +13,24 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.http.*; import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.math.BigInteger; import java.net.URI; import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; import java.util.*; /** @@ -68,6 +79,12 @@ public class StoreSubmissionService { AppVersionEntity v = versionRepo.findById(versionId).orElse(null); if (v == null) { log.error("Version not found: {}", versionId); return; } + if ("SCHEDULED".equalsIgnoreCase(v.getStoreSubmitMode())) { + v.setStoreSubmitMode("AUTO_REVIEW"); + v.setStoreSubmitScheduledAt(null); + versionRepo.save(v); + } + List targets = parseTargets(v.getStoreSubmitTargets()); if (targets.isEmpty()) { log.warn("No store targets for version {}", versionId); return; } @@ -99,6 +116,15 @@ public class StoreSubmissionService { } } + @Scheduled(fixedDelay = 60_000) + public void processScheduledStoreSubmissions() { + List due = versionRepo.findByStoreSubmitModeAndStoreSubmitScheduledAtBefore( + "SCHEDULED", java.time.LocalDateTime.now()); + for (AppVersionEntity v : due) { + executeSubmitAsync(v.getId()); + } + } + // ── Dispatch ───────────────────────────────────────────────────────────── private void submitToStore(String storeType, AppVersionEntity v, File file, @@ -111,6 +137,7 @@ public class StoreSubmissionService { case "VIVO" -> submitToVivo(v, file, creds); case "APP_STORE" -> submitToAppStore(v, file, creds); case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds); + case "HARMONY_APP" -> log.warn("Harmony app store submission is not implemented yet - keep this channel for market link and manual review tracking"); default -> throw new IllegalArgumentException("Unknown store: " + storeType); } } @@ -262,23 +289,54 @@ public class StoreSubmissionService { // API: https://dev.mi.com/distribute/doc/details?pId=1134 private void submitToMi(AppVersionEntity v, File file, Map creds) { - // Xiaomi submission is intentionally non-blocking for now. - // Keep the release flow moving even when one store channel still needs manual completion. - log.warn("MI store submission not yet implemented - leaving review state as UNDER_REVIEW for manual completion"); + try { + String account = resolveMiAccount(creds); + String publicKey = require(creds, "publicKey", "MI"); + String privateKey = require(creds, "privateKey", "MI"); + String packageName = requirePackageName(v); + JsonNode appInfo = miGetAppInfo(account, packageName, publicKey, privateKey); + String appName = appInfo.path("packageInfo").path("appName").asText(packageName); + miUploadApk(file, account, appName, packageName, v.getChangeLog(), publicKey, privateKey); + } catch (Exception e) { + throw new IllegalStateException("MI store submission failed: " + e.getMessage(), e); + } } // ── OPPO Software Store ─────────────────────────────────────────────────── // API: https://open.oppomobile.com/new/developmentDoc/info?id=11119 private void submitToOppo(AppVersionEntity v, File file, Map creds) { - log.warn("OPPO store submission not yet implemented - leaving review state as UNDER_REVIEW for manual completion"); + try { + String clientId = require(creds, "clientId", "OPPO"); + String clientSecret = require(creds, "clientSecret", "OPPO"); + String token = oppoGetToken(clientId, clientSecret); + JsonNode appInfo = oppoGetAppInfo(token, requirePackageName(v), clientId, clientSecret); + String appName = appInfo.path("appName").asText(""); + if (appName.isBlank()) { + appName = requirePackageName(v); + } + Map uploadUrl = oppoGetUploadUrl(token, clientId, clientSecret); + JsonNode apkResult = oppoUploadApk(uploadUrl, token, file, clientSecret); + oppoSubmit(v, file, token, appInfo, apkResult, clientSecret); + } catch (Exception e) { + throw new IllegalStateException("OPPO store submission failed: " + e.getMessage(), e); + } } // ── vivo App Store ──────────────────────────────────────────────────────── // API: https://dev.vivo.com.cn/documentCenter/doc/326 private void submitToVivo(AppVersionEntity v, File file, Map creds) { - log.warn("VIVO store submission not yet implemented - leaving review state as UNDER_REVIEW for manual completion"); + try { + String accessKey = require(creds, "accessKey", "VIVO"); + String accessSecret = require(creds, "accessSecret", "VIVO"); + String packageName = requirePackageName(v); + JsonNode appInfo = vivoGetAppInfo(accessKey, accessSecret, packageName); + JsonNode apkResult = vivoUploadApk(accessKey, accessSecret, file, packageName); + vivoSubmit(accessKey, accessSecret, apkResult, v.getChangeLog(), appInfo); + } catch (Exception e) { + throw new IllegalStateException("VIVO store submission failed: " + e.getMessage(), e); + } } // ── Apple App Store Connect ─────────────────────────────────────────────── @@ -296,6 +354,351 @@ public class StoreSubmissionService { // ── Utilities ───────────────────────────────────────────────────────────── + private JsonNode miGetAppInfo(String account, + String packageName, + String publicKey, + String privateKey) throws Exception { + Map requestData = new LinkedHashMap<>(); + requestData.put("userName", account); + requestData.put("packageName", packageName); + + Map sig = new LinkedHashMap<>(); + sig.put("password", privateKey); + sig.put("sig", List.of(Map.of( + "name", "RequestData", + "hash", md5Hex(asJsonString(requestData)) + ))); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("RequestData", asJsonString(requestData)); + body.add("SIG", rsaEncryptHex(asJsonString(sig), publicKey)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity response = rest.postForEntity( + "https://api.developer.xiaomi.com/devupload/dev/query", + new HttpEntity<>(body, headers), + String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + miCheckSuccess(root, "获取App信息"); + return root; + } + + private void miUploadApk(File apkFile, + String account, + String appName, + String packageName, + String updateDesc, + String publicKey, + String privateKey) throws Exception { + Map requestData = new LinkedHashMap<>(); + requestData.put("userName", account); + requestData.put("synchroType", 1); + requestData.put("appInfo", Map.of( + "appName", appName, + "packageName", packageName, + "updateDesc", updateDesc == null ? "" : updateDesc + )); + + Map sig = new LinkedHashMap<>(); + sig.put("password", privateKey); + sig.put("sig", List.of( + Map.of("name", "RequestData", "hash", md5Hex(asJsonString(requestData))), + Map.of("name", "apk", "hash", md5Hex(apkFile)) + )); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("apk", new FileSystemResource(apkFile)); + body.add("RequestData", asJsonString(requestData)); + body.add("SIG", rsaEncryptHex(asJsonString(sig), publicKey)); + + ResponseEntity response = rest.postForEntity( + "https://api.developer.xiaomi.com/devupload/dev/push", + new HttpEntity<>(body, headers), + String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + miCheckSuccess(root, "上传Apk"); + } + + private String resolveMiAccount(Map creds) { + String account = creds.get("account"); + if (account == null || account.isBlank()) { + account = creds.get("username"); + } + if (account == null || account.isBlank()) { + throw new IllegalStateException("MI credential missing: account"); + } + return account; + } + + private void miCheckSuccess(JsonNode root, String action) { + int code = root.path("result").asInt(-1); + String message = root.path("message").asText("未知"); + if (code != 0) { + throw new IllegalStateException(action + " failed: " + message); + } + } + + private JsonNode oppoGetAppInfo(String token, String packageName, String clientId, String clientSecret) throws Exception { + Map params = Map.of("pkg_name", packageName); + String requestUrl = oppoRequestUrl("https://oop-openapi-cn.heytapmobi.com/resource/v1/app/info", params, token, true, clientSecret); + ResponseEntity response = rest.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + oppoCheckSuccess(root, "获取App信息"); + return root.path("data"); + } + + private Map oppoGetUploadUrl(String token, String clientId, String clientSecret) throws Exception { + String requestUrl = oppoRequestUrl("https://oop-openapi-cn.heytapmobi.com/resource/v1/upload/get-upload-url", + Map.of(), token, true, clientSecret); + ResponseEntity response = rest.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + oppoCheckSuccess(root, "获取上传url"); + JsonNode data = root.path("data"); + return Map.of( + "url", data.path("upload_url").asText(""), + "sign", data.path("sign").asText("") + ); + } + + private JsonNode oppoUploadApk(Map uploadUrl, + String token, + File file, + String clientSecret) throws Exception { + Map params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", "")); + String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, false, clientSecret); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new FileSystemResource(file)); + body.add("type", "apk"); + body.add("sign", uploadUrl.getOrDefault("sign", "")); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + oppoCheckSuccess(root, "上传Apk"); + return root.path("data"); + } + + private void oppoSubmit(AppVersionEntity v, File file, String token, JsonNode appInfo, JsonNode apkResult, String clientSecret) throws Exception { + JsonNode apkUrl = mapper.createArrayNode().add(mapper.createObjectNode() + .put("url", apkResult.path("url").asText("")) + .put("md5", apkResult.path("md5").asText("")) + .put("cpu_code", 0)); + Map params = new LinkedHashMap<>(); + params.put("pkg_name", requirePackageName(v)); + params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode()))); + params.put("apk_url", apkUrl.toString()); + params.put("update_desc", v.getChangeLog() == null ? "" : v.getChangeLog()); + params.put("online_type", "1"); + params.put("second_category_id", appInfo.path("ver_second_category_id").asText("")); + params.put("third_category_id", appInfo.path("ver_third_category_id").asText("")); + params.put("summary", appInfo.path("summary").asText("")); + params.put("detail_desc", appInfo.path("detail_desc").asText("")); + params.put("privacy_source_url", appInfo.path("privacy_source_url").asText("")); + params.put("icon_url", appInfo.path("icon_url").asText("")); + params.put("pic_url", appInfo.path("pic_url").asText("")); + params.put("test_desc", appInfo.path("test_desc").asText("")); + params.put("business_username", appInfo.path("business_username").asText("")); + params.put("business_email", appInfo.path("business_email").asText("")); + params.put("business_mobile", appInfo.path("business_mobile").asText("")); + params.put("copyright_url", appInfo.path("copyright_url").asText(appInfo.path("electronic_cert_url").asText(""))); + params.put("electronic_cert_url", appInfo.path("electronic_cert_url").asText("")); + String requestUrl = oppoRequestUrl("https://oop-openapi-cn.heytapmobi.com/resource/v1/app/upd", params, token, false, clientSecret); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + MultiValueMap body = new LinkedMultiValueMap<>(); + params.forEach(body::add); + ResponseEntity response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + oppoCheckSuccess(root, "提交版本"); + } + + private String oppoGetToken(String clientId, String clientSecret) throws Exception { + String url = "https://oop-openapi-cn.heytapmobi.com/developer/v1/token?client_id=" + clientId + "&client_secret=" + clientSecret; + ResponseEntity response = rest.getForEntity(url, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + oppoCheckSuccess(root, "获取token"); + return root.path("data").path("access_token").asText(""); + } + + private String oppoRequestUrl(String originUrl, + Map params, + String token, + boolean paramsAppendQuery, + String clientSecret) { + String timestamp = String.valueOf(System.currentTimeMillis() / 1000); + Map signParams = new LinkedHashMap<>(params); + signParams.put("access_token", token); + signParams.put("timestamp", timestamp); + String apiSign = oppoSign(clientSecret, signParams); + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(originUrl) + .queryParam("access_token", token) + .queryParam("timestamp", timestamp) + .queryParam("api_sign", apiSign); + if (paramsAppendQuery) { + params.forEach(builder::queryParam); + } + return builder.toUriString(); + } + + private void oppoCheckSuccess(JsonNode root, String action) { + int code = root.path("errno").asInt(-1); + String message = root.path("data").path("message").asText(""); + if (code != 0) { + throw new IllegalStateException(action + " failed: " + message); + } + } + + private String oppoSign(String secret, Map paramsMap) { + List keys = new ArrayList<>(paramsMap.keySet()); + Collections.sort(keys); + List parts = new ArrayList<>(); + for (String key : keys) { + String value = paramsMap.get(key); + if (value == null) continue; + parts.add(key + "=" + value); + } + return hmacSha256(String.join("&", parts), secret); + } + + private JsonNode vivoGetAppInfo(String accessKey, String accessSecret, String packageName) throws Exception { + String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.query.details", Map.of("packageName", packageName)); + ResponseEntity response = rest.getForEntity(requestUrl, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + vivoCheckSuccess(root, "查询应用详情"); + return root.path("data"); + } + + private JsonNode vivoUploadApk(String accessKey, String accessSecret, File file, String packageName) throws Exception { + Map params = Map.of( + "packageName", packageName, + "fileMd5", md5Hex(file) + ); + String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.upload.apk.app", params); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", new FileSystemResource(file)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + ResponseEntity response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + vivoCheckSuccess(root, "上传apk"); + return root.path("data"); + } + + private void vivoSubmit(String accessKey, + String accessSecret, + JsonNode apkResult, + String updateDesc, + JsonNode appInfo) throws Exception { + Map params = new LinkedHashMap<>(); + params.put("packageName", apkResult.path("packageName").asText("")); + params.put("versionCode", apkResult.path("versionCode").asText("")); + params.put("apk", apkResult.path("serialnumber").asText("")); + params.put("fileMd5", apkResult.path("fileMd5").asText("")); + params.put("onlineType", String.valueOf(appInfo.path("onlineType").asInt(1))); + params.put("updateDesc", updateDesc == null ? "" : updateDesc); + String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.sync.update.app", params); + ResponseEntity response = rest.getForEntity(requestUrl, String.class); + JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + vivoCheckSuccess(root, "提交更新"); + } + + private String vivoRequestUrl(String accessKey, String accessSecret, String method, Map originParams) { + Map params = new LinkedHashMap<>(originParams); + params.put("access_key", accessKey); + params.put("timestamp", String.valueOf(System.currentTimeMillis())); + params.put("method", method); + params.put("v", "1.0"); + params.put("sign_method", "HMAC-SHA256"); + params.put("format", "json"); + params.put("target_app_key", "developer"); + String data = params.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .reduce((a, b) -> a + "&" + b) + .orElse(""); + String sign = hmacSha256(data, accessSecret); + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl("https://developer-api.vivo.com.cn/router/rest"); + params.forEach(builder::queryParam); + builder.queryParam("sign", sign); + return builder.toUriString(); + } + + private void vivoCheckSuccess(JsonNode root, String action) { + int code = root.path("code").asInt(-1); + int subCode = root.path("subCode").asText("-1").chars().allMatch(Character::isDigit) + ? Integer.parseInt(root.path("subCode").asText("0")) : -1; + String msg = root.path("msg").asText("未知"); + if (code != 0 || subCode != 0) { + throw new IllegalStateException(action + " failed: " + msg); + } + } + + private String hmacSha256(String data, String key) { + try { + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + javax.crypto.spec.SecretKeySpec signingKey = new javax.crypto.spec.SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(signingKey); + byte[] result = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(result.length * 2); + for (byte b : result) { + String hex = Integer.toHexString(b & 0xFF); + if (hex.length() == 1) sb.append('0'); + sb.append(hex); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException("failed to sign request", e); + } + } + + private String rsaEncryptHex(String content, String publicKeyPem) throws Exception { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + try (InputStream inputStream = new java.io.ByteArrayInputStream(publicKeyPem.getBytes(StandardCharsets.UTF_8))) { + X509Certificate certificate = (X509Certificate) factory.generateCertificate(inputStream); + PublicKey publicKey = certificate.getPublicKey(); + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, publicKey); + byte[] encrypted = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(encrypted); + } + } + + private String md5Hex(File file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("MD5"); + try (InputStream inputStream = new FileInputStream(file)) { + byte[] buffer = new byte[8192]; + int len; + while ((len = inputStream.read(buffer)) != -1) { + digest.update(buffer, 0, len); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private String md5Hex(String text) throws Exception { + MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(text.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest.digest()); + } + + private String asJsonString(Object value) throws Exception { + return mapper.writeValueAsString(value); + } + + private String requirePackageName(AppVersionEntity v) { + if (v.getPackageName() == null || v.getPackageName().isBlank()) { + throw new IllegalStateException("packageName is required for store submission"); + } + return v.getPackageName(); + } + + private int parseVersionCode(Integer versionCode) { + return versionCode == null ? 0 : versionCode; + } + private File resolveLocalFile(String downloadUrl) { if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null"); String path = URI.create(downloadUrl).getPath(); diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index 3c3b03d..db4f28e 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -1,5 +1,7 @@ package com.xuqm.update.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.update.model.AppPackageInspectResult; import com.xuqm.update.model.RnBundleInspectResult; import net.dongliu.apk.parser.ApkFile; @@ -40,6 +42,7 @@ import org.w3c.dom.NodeList; public class UpdateAssetService { private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class); + private final ObjectMapper objectMapper; @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; @@ -47,6 +50,10 @@ public class UpdateAssetService { @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; + public UpdateAssetService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + public String storeAppPackage(MultipartFile apkFile) throws IOException { if (apkFile == null || apkFile.isEmpty()) { return null; @@ -109,6 +116,20 @@ public class UpdateAssetService { if (bundle == null || bundle.isEmpty()) { return new RnBundleInspectResult(null, null, null, null, null, fileName, false); } + if (isZipBundle(fileName)) { + Path temp = Files.createTempFile("xuqm-rn-bundle-inspect-", suffixFor(fileName)); + try { + try (InputStream in = bundle.getInputStream()) { + Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); + } + RnBundleInspectResult zipped = inspectRnBundleZip(temp, fileName); + if (zipped.detected()) { + return zipped; + } + } finally { + Files.deleteIfExists(temp); + } + } return inspectRnBundleName(fileName); } @@ -116,7 +137,7 @@ public class UpdateAssetService { if (bundle == null || bundle.isEmpty()) { throw new IllegalArgumentException("bundle file is required"); } - String filename = moduleId + "." + platform.toLowerCase() + ".bundle"; + String filename = resolveBundleFilename(moduleId, platform, bundle.getOriginalFilename()); Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId); Files.createDirectories(dir); Path dest = dir.resolve(filename); @@ -209,6 +230,34 @@ public class UpdateAssetService { false); } + private RnBundleInspectResult inspectRnBundleZip(Path file, String fileName) throws Exception { + try (ZipFile zipFile = new ZipFile(file.toFile())) { + ZipEntry entry = zipFile.getEntry("rn-manifest.json"); + if (entry == null) { + entry = zipFile.getEntry("manifest.json"); + } + if (entry != null) { + try (InputStream in = zipFile.getInputStream(entry)) { + JsonNode node = objectMapper.readTree(in); + String moduleId = text(node, "moduleId"); + String platform = text(node, "platform"); + String version = firstText(node, "bundleVersion", "version"); + String minCommonVersion = text(node, "minCommonVersion"); + String packageName = text(node, "packageName"); + return new RnBundleInspectResult( + moduleId, + platformFromToken(platform), + version, + minCommonVersion, + packageName, + fileName, + hasText(moduleId) && hasText(version) && hasText(platform)); + } + } + } + return inspectRnBundleName(fileName); + } + private Map parsePlistXml(String xml) throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(false); @@ -270,6 +319,24 @@ public class UpdateAssetService { return idx > 0 ? fileName.substring(idx) : ".tmp"; } + private boolean isZipBundle(String fileName) { + String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT); + return lower.endsWith(".zip") || lower.endsWith(".bundle.zip") || lower.endsWith(".tar.gz"); + } + + private String resolveBundleFilename(String moduleId, String platform, String originalFilename) { + String safeOriginal = Optional.ofNullable(originalFilename).orElse("").trim(); + String ext = ""; + int idx = safeOriginal.lastIndexOf('.'); + if (idx > 0 && idx < safeOriginal.length() - 1) { + ext = safeOriginal.substring(idx); + } + if (!hasText(ext)) { + ext = ".zip"; + } + return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext; + } + private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException { HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection(); connection.setConnectTimeout(15_000); @@ -371,6 +438,19 @@ public class UpdateAssetService { return value == null || value.isBlank() ? null : value.trim(); } + private String text(JsonNode node, String field) { + if (node == null || !node.hasNonNull(field)) { + return null; + } + String value = node.get(field).asText(); + return blankToNull(value); + } + + private String firstText(JsonNode node, String primaryField, String fallbackField) { + String primary = text(node, primaryField); + return hasText(primary) ? primary : text(node, fallbackField); + } + private AppPackageInspectResult fallbackInspectResult(String source) { String fileName = source; try {