From f5a1eb44702b035a210f25e7ec23754cc36ed999 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 17:35:52 +0800 Subject: [PATCH] =?UTF-8?q?docs(sdk):=20=E6=B7=BB=E5=8A=A0=20React=20Nativ?= =?UTF-8?q?e=20SDK=20=E6=96=87=E6=A1=A3=E5=92=8C=20Android/HarmonyOS=20?= =?UTF-8?q?=E5=8F=91=E7=89=88=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明 - 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务 - 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能 - 实现多平台版本检查、自动重连、灰度发布等发版流程自动化 - 集成商店提交、定时发布、Webhook 回调等发布后处理功能 --- README.md | 13 +- docs/API_ACCESS.md | 53 +++++- .../com/xuqm/file/config/SecurityConfig.java | 29 +++ .../src/main/resources/application.yml | 2 +- .../controller/FeatureServiceController.java | 75 +++++--- .../controller/SdkConfigController.java | 86 ++++++++- .../tenant/service/FeatureServiceManager.java | 122 +++++++++++++ .../service/SdkAppProvisioningService.java | 13 +- .../src/main/resources/application.yml | 2 +- .../xuqm/update/config/SecurityConfig.java | 2 + .../controller/AppVersionController.java | 34 +++- .../update/controller/RnBundleController.java | 2 +- .../service/StoreSubmissionService.java | 21 ++- .../update/service/UpdateAssetService.java | 167 +++++++++++++++++- 14 files changed, 558 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 64aac88..94ce9b5 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ push: | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/updates/app/check` | 检查更新 | -| POST | `/api/v1/updates/app/upload` | 上传版本(APK 文件 multipart) | +| POST | `/api/v1/updates/app/upload` | 上传版本(先传 file-service,再把 `apkUrl` 交给 update-service) | | POST | `/api/v1/updates/app/{id}/publish` | 发布版本 | | GET | `/api/v1/updates/app/list` | 版本列表 | @@ -464,7 +464,14 @@ GET /api/v1/updates/app/check **platform 枚举**:`ANDROID` / `IOS` / `HARMONY` -**上传 APK(multipart/form-data)** +**上传 APK(两段式)** +``` +POST /api/file/upload + file= +``` + +拿到返回的 `url` 后,再调用: + ``` POST /api/v1/updates/app/upload appId=ak_xxx @@ -473,7 +480,7 @@ POST /api/v1/updates/app/upload versionCode=11 changeLog=更新内容 forceUpdate=false - apkFile= + apkUrl=https://file.dev.xuqinmin.com/api/file/ ``` ### RN Bundle 管理 diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index cd985f4..fbfe374 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -88,6 +88,8 @@ | PUT | `/api/apps/{id}` | 是 | 更新应用 | | DELETE | `/api/apps/{id}` | 是 | 删除应用 | | GET | `/api/apps/{appId}/services` | 是 | 服务列表 | +| GET | `/api/apps/{appId}/services/item` | 是 | 按平台和服务类型查询单条服务配置 | +| PUT | `/api/apps/{appId}/services/config` | 是 | 更新服务配置,IM 和 UPDATE 走各自的配置模型 | | POST | `/api/apps/{appId}/services/toggle` | 是 | 开关服务 | | POST | `/api/apps/{appId}/services/{id}/regenerate-key` | 是 | 重新生成服务密钥 | | GET | `/api/sub-accounts` | 是 | 子账号列表 | @@ -133,7 +135,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| -| POST | `/api/file/upload` | 是 | 文件上传,按 SHA-256 去重 | +| POST | `/api/file/upload` | 是 | 文件上传,按 SHA-256 去重,返回 `url` / `hash` / `originalName` | | GET | `/api/file/{hash}` | 否 | 按 hash 获取文件 | | GET | `/api/file/{hash}/thumbnail` | 否 | 按 hash 获取缩略图 | @@ -142,7 +144,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | -| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Harmony 版本仅保存市场链接,不提供本地安装包下载 | +| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android / iOS 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,Harmony 版本仅保存市场链接,不提供本地安装包下载 | | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | @@ -151,7 +153,52 @@ | POST | `/api/v1/rn/{id}/publish` | 是 | 发布 Bundle | | GET | `/api/v1/rn/files/{appId}/{platform}/{moduleId}` | 否 | 下载 Bundle | -说明:这里的 `appId` 按 `appKey` 解析。当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用;如果没有,会自动补一条默认 demo 应用和基础服务配置。`POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 +### tenant-service 提供给 update-sdk 的公共配置接口 + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | `/api/sdk/config` | 否 | 发版脚本读取租户级默认配置;`appId` 可以传 `appKey`,`platform` 用于读取当前平台的 UPDATE 配置 | + +说明: + +- 这里的 `appId` 按 `appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。 +- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 +- `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` 的流程,不会因为解析失败直接中断。 + +## update-sdk 自动发版 + +三个移动端 SDK 现在都支持通过脚本完成“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”的流程。 + +脚本支持的公共参数: + +- `xuqm.serverUrl` +- `xuqm.tenantUrl` +- `xuqm.appKey` +- `xuqm.apiToken` +- `xuqm.dryRun` +- `xuqm.allowVersionMismatch` +- `xuqm.publishMode` +- `xuqm.publishImmediately` +- `xuqm.scheduledPublishAt` +- `xuqm.autoPublishAfterReview` +- `xuqm.webhookUrl` +- `xuqm.forceUpdate` +- `xuqm.grayEnabled` +- `xuqm.grayPercent` +- `xuqm.storeTargets` + +推荐流程: + +1. 脚本先调用 `GET /api/sdk/config?appId=&platform=`。 +2. 如果服务未开通或配置缺失,则进入 dry-run 或提示补齐配置。 +3. 脚本读取服务器最新版本,和本地版本对比。 +4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。 +5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 +6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 + +租户平台里的“发版默认配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 ## curl 示例 diff --git a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java index 48715e3..5a5ee67 100644 --- a/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java +++ b/file-service/src/main/java/com/xuqm/file/config/SecurityConfig.java @@ -4,12 +4,18 @@ import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtUtil; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -25,8 +31,10 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> {}) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // Public: serve files by hash and thumbnails .requestMatchers("/api/file/*/thumbnail").permitAll() .requestMatchers("/api/file/*").permitAll() @@ -38,4 +46,25 @@ public class SecurityConfig { .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.116.9:*", + "http://*.xuqinmin.com", + "https://*.xuqinmin.com" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Content-Disposition", "Location")); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/file-service/src/main/resources/application.yml b/file-service/src/main/resources/application.yml index 0693cb0..1201fd3 100644 --- a/file-service/src/main/resources/application.yml +++ b/file-service/src/main/resources/application.yml @@ -41,7 +41,7 @@ jwt: file: upload-dir: ${FILE_UPLOAD_DIR:/tmp/xuqm-file-upload} - base-url: ${FILE_BASE_URL:https://file.dev.xuqinmin.com} + base-url: ${FILE_BASE_URL:http://192.168.116.9:8086} logging: level: diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index b5ac5aa..a63ec87 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -17,7 +17,6 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.List; -import java.util.Map; @RestController @RequestMapping("/api/apps/{appId}/services") @@ -38,6 +37,16 @@ public class FeatureServiceController { return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listByApp(appId))); } + @GetMapping("/item") + public ResponseEntity> get( + @PathVariable String appId, + @RequestParam FeatureServiceEntity.Platform platform, + @RequestParam FeatureServiceEntity.ServiceType serviceType, + @AuthenticationPrincipal String tenantId) { + appService.getById(appId, tenantId); + return ResponseEntity.ok(ApiResponse.success(featureServiceManager.getByPlatform(appId, platform, serviceType))); + } + /** Disable a service (enable=false only; enabling requires ops approval via request-activation). */ @PostMapping("/toggle") public ResponseEntity> toggle( @@ -62,26 +71,38 @@ public class FeatureServiceController { @RequestBody FeatureServiceConfigRequest req, @AuthenticationPrincipal String tenantId) { appService.getById(appId, tenantId); + String config = switch (serviceType) { + case IM -> featureServiceManager.buildImConfig( + appId, + platform, + req == null ? null : req.allowStrangerMessage(), + req == null ? null : req.allowFriendRequest(), + req == null ? null : req.friendRequestMode(), + req == null ? null : req.allowGroupJoinRequest(), + req == null ? null : req.blacklistSendSuccess(), + req == null ? null : req.messageRecallMinutes(), + req == null ? null : req.historyRetentionDays(), + req == null ? null : req.conversationPullLimit(), + req == null ? null : req.multiClientConversationDeleteSync()); + case UPDATE -> featureServiceManager.buildUpdateConfig( + appId, + platform, + req == null ? null : req.defaultStoreTargets(), + req == null ? null : req.defaultPublishMode(), + req == null ? null : req.defaultPublishImmediately(), + req == null ? null : req.defaultScheduledPublishAt(), + req == null ? null : req.defaultAutoPublishAfterReview(), + req == null ? null : req.defaultWebhookUrl(), + req == null ? null : req.defaultForceUpdate(), + req == null ? null : req.defaultGrayEnabled(), + req == null ? null : req.defaultGrayPercent(), + req == null ? null : req.defaultPackageName(), + req == null ? null : req.defaultAppStoreUrl(), + req == null ? null : req.defaultMarketUrl()); + case PUSH -> "{}"; + }; return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig( - appId, - platform, - serviceType, - serviceType == FeatureServiceEntity.ServiceType.IM - ? featureServiceManager.buildImConfig( - appId, - platform, - req == null ? null : req.allowStrangerMessage(), - req == null ? null : req.allowFriendRequest(), - req == null ? null : req.friendRequestMode(), - req == null ? null : req.allowGroupJoinRequest(), - req == null ? null : req.blacklistSendSuccess(), - req == null ? null : req.messageRecallMinutes(), - req == null ? null : req.historyRetentionDays(), - req == null ? null : req.conversationPullLimit(), - req == null ? null : req.multiClientConversationDeleteSync()) - : featureServiceManager.buildAllowStrangerConfig( - req != null && Boolean.TRUE.equals(req.allowStrangerMessage())) - ))); + appId, platform, serviceType, config))); } /** Submit an activation request for ops approval. */ @@ -113,6 +134,18 @@ public class FeatureServiceController { Integer messageRecallMinutes, Integer historyRetentionDays, Integer conversationPullLimit, - Boolean multiClientConversationDeleteSync + Boolean multiClientConversationDeleteSync, + List defaultStoreTargets, + String defaultPublishMode, + Boolean defaultPublishImmediately, + String defaultScheduledPublishAt, + Boolean defaultAutoPublishAfterReview, + String defaultWebhookUrl, + Boolean defaultForceUpdate, + Boolean defaultGrayEnabled, + Integer defaultGrayPercent, + String defaultPackageName, + String defaultAppStoreUrl, + String defaultMarketUrl ) {} } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java index 5585bb1..d9c250d 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SdkConfigController.java @@ -1,5 +1,7 @@ package com.xuqm.tenant.controller; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; @@ -21,31 +23,37 @@ public class SdkConfigController { private final FeatureServiceRepository featureServiceRepository; private final SdkAppProvisioningService sdkAppProvisioningService; + private final ObjectMapper objectMapper; @Value("${sdk.im-ws-url:wss://im.dev.xuqinmin.com/ws/im}") private String imWsUrl; - @Value("${sdk.file-service-url:https://file.dev.xuqinmin.com}") + @Value("${sdk.file-service-url:http://192.168.116.9:8086}") private String fileServiceUrl; @Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}") private String imApiUrl; public SdkConfigController(FeatureServiceRepository featureServiceRepository, - SdkAppProvisioningService sdkAppProvisioningService) { + SdkAppProvisioningService sdkAppProvisioningService, + ObjectMapper objectMapper) { this.featureServiceRepository = featureServiceRepository; this.sdkAppProvisioningService = sdkAppProvisioningService; + this.objectMapper = objectMapper; } /** - * GET /api/sdk/config?appId=XXX — public, no auth required. + * GET /api/sdk/config?appId=XXX&platform=ANDROID — public, no auth required. * * Returns SDK configuration URLs and enabled feature flags for the given appId/appKey. * The demo app (`ak_demo_chat`) is auto-provisioned if it does not exist. + * For update releases, the platform-specific UPDATE row drives both the enabled flag and + * the default release configuration. */ @GetMapping("/config") public ResponseEntity> getConfig( - @RequestParam String appId) { + @RequestParam String appId, + @RequestParam(required = false, defaultValue = "ANDROID") FeatureServiceEntity.Platform platform) { AppEntity app = sdkAppProvisioningService.resolveApp(appId); List features = featureServiceRepository.findByAppId(app.getAppKey()); @@ -54,8 +62,14 @@ public class SdkConfigController { .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.IM && f.isEnabled()); boolean pushEnabled = features.stream() .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && f.isEnabled()); - boolean updateEnabled = features.stream() - .anyMatch(f -> f.getServiceType() == FeatureServiceEntity.ServiceType.UPDATE && f.isEnabled()); + JsonNode updateConfig = featureServiceRepository + .findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) + .map(feature -> parseConfig(feature.getConfig())) + .orElseGet(objectMapper::createObjectNode); + boolean updateEnabled = featureServiceRepository + .findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE) + .map(FeatureServiceEntity::isEnabled) + .orElse(false); SdkConfigResponse response = new SdkConfigResponse( imWsUrl, @@ -65,7 +79,20 @@ public class SdkConfigController { "im", imEnabled, "push", pushEnabled, "update", updateEnabled - ) + ), + updateEnabled, + updateConfig.path("defaultPublishMode").asText("MANUAL"), + updateConfig.path("defaultPublishImmediately").asBoolean(false), + updateConfig.path("defaultScheduledPublishAt").asText(""), + updateConfig.path("defaultAutoPublishAfterReview").asBoolean(false), + updateConfig.path("defaultWebhookUrl").asText(""), + csv(updateConfig.path("defaultStoreTargets")), + updateConfig.path("defaultForceUpdate").asBoolean(false), + updateConfig.path("defaultGrayEnabled").asBoolean(false), + updateConfig.path("defaultGrayPercent").asInt(0), + updateConfig.path("defaultPackageName").asText(""), + updateConfig.path("defaultAppStoreUrl").asText(""), + updateConfig.path("defaultMarketUrl").asText("") ); return ResponseEntity.ok(ApiResponse.success(response)); @@ -75,6 +102,49 @@ public class SdkConfigController { String imWsUrl, String fileServiceUrl, String imApiUrl, - Map features + Map features, + boolean updateEnabled, + String updateDefaultPublishMode, + boolean updateDefaultPublishImmediately, + String updateDefaultScheduledPublishAt, + boolean updateDefaultAutoPublishAfterReview, + String updateDefaultWebhookUrl, + String updateDefaultStoreTargets, + boolean updateDefaultForceUpdate, + boolean updateDefaultGrayEnabled, + int updateDefaultGrayPercent, + String updateDefaultPackageName, + String updateDefaultAppStoreUrl, + String updateDefaultMarketUrl ) {} + + private JsonNode parseConfig(String config) { + if (config == null || config.isBlank()) { + return objectMapper.createObjectNode(); + } + try { + JsonNode node = objectMapper.readTree(config); + return node == null ? objectMapper.createObjectNode() : node; + } catch (Exception e) { + return objectMapper.createObjectNode(); + } + } + + private String csv(JsonNode node) { + if (node == null || !node.isArray()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (JsonNode item : node) { + String value = item.asText(null); + if (value == null || value.isBlank()) { + continue; + } + if (!sb.isEmpty()) { + sb.append(','); + } + sb.append(value.trim()); + } + return sb.toString(); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index 1f90d47..e1ceb40 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -2,6 +2,7 @@ package com.xuqm.tenant.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.entity.FeatureServiceEntity; @@ -208,6 +209,12 @@ public class FeatureServiceManager { return repository.save(entity); } + public FeatureServiceEntity getByPlatform(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + return getOrFail(appId, platform, serviceType); + } + public boolean allowStrangerMessage(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { @@ -355,6 +362,113 @@ public class FeatureServiceManager { return node.toString(); } + public String buildUpdateConfig(String appId, + FeatureServiceEntity.Platform platform, + List defaultStoreTargets, + String defaultPublishMode, + Boolean defaultPublishImmediately, + String defaultScheduledPublishAt, + Boolean defaultAutoPublishAfterReview, + String defaultWebhookUrl, + Boolean defaultForceUpdate, + Boolean defaultGrayEnabled, + Integer defaultGrayPercent, + String defaultPackageName, + String defaultAppStoreUrl, + String defaultMarketUrl) { + ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.UPDATE).deepCopy(); + if (!node.has("defaultStoreTargets")) { + node.putArray("defaultStoreTargets"); + } + if (!node.has("defaultPublishMode")) { + node.put("defaultPublishMode", "MANUAL"); + } + if (!node.has("defaultPublishImmediately")) { + node.put("defaultPublishImmediately", false); + } + if (!node.has("defaultScheduledPublishAt")) { + node.put("defaultScheduledPublishAt", ""); + } + if (!node.has("defaultAutoPublishAfterReview")) { + node.put("defaultAutoPublishAfterReview", false); + } + if (!node.has("defaultWebhookUrl")) { + node.put("defaultWebhookUrl", ""); + } + if (!node.has("defaultForceUpdate")) { + node.put("defaultForceUpdate", false); + } + if (!node.has("defaultGrayEnabled")) { + node.put("defaultGrayEnabled", false); + } + if (!node.has("defaultGrayPercent")) { + node.put("defaultGrayPercent", 0); + } + if (!node.has("defaultPackageName")) { + node.put("defaultPackageName", ""); + } + if (!node.has("defaultAppStoreUrl")) { + node.put("defaultAppStoreUrl", ""); + } + if (!node.has("defaultMarketUrl")) { + node.put("defaultMarketUrl", ""); + } + + if (defaultStoreTargets != null) { + node.remove("defaultStoreTargets"); + ArrayNode array = node.putArray("defaultStoreTargets"); + defaultStoreTargets.stream() + .filter(v -> v != null && !v.isBlank()) + .map(v -> v.trim().toUpperCase()) + .forEach(array::add); + } + if (defaultPublishMode != null && !defaultPublishMode.isBlank()) { + node.put("defaultPublishMode", normalizeReleaseMode(defaultPublishMode)); + } + if (defaultPublishImmediately != null) { + node.put("defaultPublishImmediately", defaultPublishImmediately); + } + if (defaultScheduledPublishAt != null) { + node.put("defaultScheduledPublishAt", defaultScheduledPublishAt.trim()); + } + if (defaultAutoPublishAfterReview != null) { + node.put("defaultAutoPublishAfterReview", defaultAutoPublishAfterReview); + } + if (defaultWebhookUrl != null) { + node.put("defaultWebhookUrl", defaultWebhookUrl.trim()); + } + if (defaultForceUpdate != null) { + node.put("defaultForceUpdate", defaultForceUpdate); + } + if (defaultGrayEnabled != null) { + node.put("defaultGrayEnabled", defaultGrayEnabled); + } + if (defaultGrayPercent != null) { + node.put("defaultGrayPercent", Math.min(Math.max(defaultGrayPercent, 0), 100)); + } + if (defaultPackageName != null) { + node.put("defaultPackageName", defaultPackageName.trim()); + } + if (defaultAppStoreUrl != null) { + node.put("defaultAppStoreUrl", defaultAppStoreUrl.trim()); + } + if (defaultMarketUrl != null) { + node.put("defaultMarketUrl", defaultMarketUrl.trim()); + } + return node.toString(); + } + + public List parseStoreTargets(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(json, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } + private String normalizeFriendRequestMode(String mode) { String normalized = mode == null ? "" : mode.trim().toUpperCase(); return switch (normalized) { @@ -363,6 +477,14 @@ public class FeatureServiceManager { }; } + private String normalizeReleaseMode(String mode) { + String normalized = mode == null ? "" : mode.trim().toUpperCase(); + return switch (normalized) { + case "NOW", "SCHEDULED", "AUTO_REVIEW" -> normalized; + default -> "MANUAL"; + }; + } + private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) { return serviceType == FeatureServiceEntity.ServiceType.IM || serviceType == FeatureServiceEntity.ServiceType.PUSH diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java index b3ee024..e85dab4 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -149,7 +149,18 @@ public class SdkAppProvisioningService { feature.setPlatform(platform); feature.setServiceType(serviceType); feature.setEnabled(true); - feature.setConfig(null); + feature.setConfig(serviceType == FeatureServiceEntity.ServiceType.UPDATE + ? switch (platform) { + case ANDROID -> """ + {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + case IOS -> """ + {"defaultStoreTargets":["APP_STORE"],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + case HARMONY -> """ + {"defaultStoreTargets":[],"defaultPublishMode":"MANUAL","defaultPublishImmediately":false,"defaultScheduledPublishAt":"","defaultAutoPublishAfterReview":false,"defaultWebhookUrl":"","defaultForceUpdate":false,"defaultGrayEnabled":false,"defaultGrayPercent":0,"defaultPackageName":"","defaultAppStoreUrl":"","defaultMarketUrl":""} + """.trim(); + } : null); feature.setCreatedAt(LocalDateTime.now()); return featureServiceRepository.save(feature); }); diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index e56a2c1..75a2605 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -86,5 +86,5 @@ sdk: bootstrap-app-package: ${SDK_BOOTSTRAP_APP_PACKAGE:com.xuqm.demo} bootstrap-app-description: ${SDK_BOOTSTRAP_APP_DESCRIPTION:XuqmGroup demo app} im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im} - file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com} + file-service-url: ${SDK_FILE_SERVICE_URL:http://192.168.116.9:8086} im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com} diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index 122ee36..d685be7 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -38,7 +38,9 @@ public class SecurityConfig { .requestMatchers( "/actuator/**", "/api/v1/updates/app/check", + "/api/v1/updates/app/inspect", "/api/v1/rn/update/check", + "/api/v1/rn/inspect", "/api/v1/rn/files/**", "/files/apk/**" ).permitAll() 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 04f90fb..1dff1b7 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 @@ -4,6 +4,8 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.model.AppPackageInspectResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -19,6 +21,8 @@ import com.xuqm.update.service.UpdateAssetService; @RequestMapping("/api/v1/updates") public class AppVersionController { + private static final Logger log = LoggerFactory.getLogger(AppVersionController.class); + private final AppVersionRepository versionRepository; private final UpdateAssetService updateAssetService; @@ -62,6 +66,7 @@ public class AppVersionController { @RequestParam(required = false) Integer versionCode, @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, + @RequestParam(required = false) String apkUrl, @RequestParam(required = false) MultipartFile apkFile, @RequestParam(required = false) String scheduledPublishAt, @RequestParam(required = false) String webhookUrl, @@ -72,17 +77,23 @@ public class AppVersionController { @RequestParam(required = false) String appStoreUrl, @RequestParam(required = false) String marketUrl) throws Exception { - AppPackageInspectResult inspected = apkFile != null && !apkFile.isEmpty() - ? updateAssetService.inspectAppPackage(apkFile) - : null; + AppPackageInspectResult inspected = null; + try { + inspected = hasText(apkUrl) + ? updateAssetService.inspectAppPackage(apkUrl) + : (apkFile != null && !apkFile.isEmpty() ? updateAssetService.inspectAppPackage(apkFile) : null); + } catch (Exception ex) { + log.warn("Unable to inspect upload package for appId={}, platform={}, source={}, fallback to manual version fields: {}", + appId, platform, hasText(apkUrl) ? apkUrl : (apkFile != null ? apkFile.getOriginalFilename() : null), ex.getMessage()); + } String resolvedVersionName = hasText(versionName) ? versionName : (inspected != null ? inspected.versionName() : null); Integer resolvedVersionCode = versionCode != null ? versionCode : (inspected != null ? inspected.versionCode() : null); String resolvedPackageName = hasText(packageName) ? packageName : (inspected != null ? inspected.packageName() : null); 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 && (apkFile == null || apkFile.isEmpty())) { - throw new IllegalArgumentException("apkFile is required for ANDROID and IOS releases"); + 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 (platform == AppVersionEntity.Platform.HARMONY && !hasText(marketUrl)) { throw new IllegalArgumentException("marketUrl is required for HARMONY releases"); @@ -97,7 +108,9 @@ public class AppVersionController { entity.setPlatform(platform); entity.setVersionName(resolvedVersionName); entity.setVersionCode(resolvedVersionCode); - entity.setDownloadUrl(platform == AppVersionEntity.Platform.HARMONY ? null : updateAssetService.storeAppPackage(apkFile)); + entity.setDownloadUrl(platform == AppVersionEntity.Platform.HARMONY + ? null + : (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))); entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); @@ -119,8 +132,13 @@ public class AppVersionController { return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); } - @PostMapping("/app/inspect") - public ResponseEntity> inspect(@RequestParam(required = false) MultipartFile apkFile) throws Exception { + @RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity> inspect( + @RequestParam(required = false) String apkUrl, + @RequestParam(required = false) MultipartFile apkFile) throws Exception { + if (hasText(apkUrl)) { + return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkUrl))); + } return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile))); } 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 aad25e2..3ca6c21 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 @@ -101,7 +101,7 @@ public class RnBundleController { return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } - @PostMapping("/inspect") + @RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST}) public ResponseEntity> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception { return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle))); } 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 c02cae0..fd1832b 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 @@ -44,19 +44,19 @@ public class StoreSubmissionService { private final AppVersionRepository versionRepo; private final AppStoreConfigRepository configRepo; private final AppStoreService storeService; + private final UpdateAssetService updateAssetService; @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; - @Value("${update.base-url:https://update.dev.xuqinmin.com}") - private String baseUrl; - public StoreSubmissionService(AppVersionRepository versionRepo, AppStoreConfigRepository configRepo, - AppStoreService storeService) { + AppStoreService storeService, + UpdateAssetService updateAssetService) { this.versionRepo = versionRepo; this.configRepo = configRepo; this.storeService = storeService; + this.updateAssetService = updateAssetService; } /** @@ -299,11 +299,16 @@ public class StoreSubmissionService { private File resolveLocalFile(String downloadUrl) { if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null"); String path = URI.create(downloadUrl).getPath(); - // path like /files/apk/{filename} or /api/v1/updates/files/apk/{filename} String filename = Paths.get(path).getFileName().toString(); - File file = Paths.get(uploadDir, "apk", filename).toFile(); - if (!file.exists()) throw new IllegalStateException("APK file not found locally: " + file); - return file; + File local = Paths.get(uploadDir, "apk", filename).toFile(); + if (local.exists()) { + return local; + } + try { + return updateAssetService.downloadRemotePackageToCache(downloadUrl).toFile(); + } catch (Exception e) { + throw new IllegalStateException("APK file not found locally and remote download failed: " + downloadUrl, e); + } } private List parseTargets(String json) { 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 6042558..3c3b03d 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 @@ -5,11 +5,17 @@ import com.xuqm.update.model.RnBundleInspectResult; import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.bean.ApkMeta; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ContentDisposition; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -33,6 +39,8 @@ import org.w3c.dom.NodeList; @Service public class UpdateAssetService { + private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class); + @Value("${update.upload-dir:/tmp/xuqm-update}") private String uploadDir; @@ -51,10 +59,27 @@ public class UpdateAssetService { return baseUrl + "/files/apk/" + filename; } + public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception { + if (packageUrl == null || packageUrl.isBlank()) { + return new AppPackageInspectResult(null, null, null, null, null, false); + } + + try { + RemotePackage remote = downloadRemotePackage(packageUrl, true); + try { + return inspectDownloadedPackage(remote.path(), remote.fileName()); + } finally { + Files.deleteIfExists(remote.path()); + } + } catch (Exception ex) { + log.warn("Failed to inspect remote package {}, fallback to undetected result: {}", packageUrl, ex.getMessage()); + return fallbackInspectResult(packageUrl); + } + } + public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception { String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null) .orElse(""); - String normalized = fileName.toLowerCase(Locale.ROOT); if (packageFile == null || packageFile.isEmpty()) { return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false); } @@ -65,18 +90,19 @@ public class UpdateAssetService { Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); } - if (normalized.endsWith(".apk")) { - return inspectApk(temp, fileName); - } - if (normalized.endsWith(".ipa")) { - return inspectIpa(temp, fileName); - } - return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false); + return inspectDownloadedPackage(temp, fileName); + } catch (Exception ex) { + log.warn("Failed to inspect uploaded package {}, fallback to undetected result: {}", fileName, ex.getMessage()); + return fallbackInspectResult(fileName); } finally { Files.deleteIfExists(temp); } } + public Path downloadRemotePackageToCache(String packageUrl) throws IOException { + return downloadRemotePackage(packageUrl, false).path(); + } + public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception { String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null) .orElse(""); @@ -125,6 +151,17 @@ public class UpdateAssetService { } } + private AppPackageInspectResult inspectDownloadedPackage(Path file, String fileName) throws Exception { + String normalized = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT); + if (normalized.endsWith(".apk")) { + return inspectApk(file, fileName); + } + if (normalized.endsWith(".ipa")) { + return inspectIpa(file, fileName); + } + return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false); + } + private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception { try (ZipFile zipFile = new ZipFile(file.toFile())) { ZipEntry entry = zipFile.stream() @@ -233,6 +270,94 @@ public class UpdateAssetService { return idx > 0 ? fileName.substring(idx) : ".tmp"; } + private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection(); + connection.setConnectTimeout(15_000); + connection.setReadTimeout(30_000); + connection.setInstanceFollowRedirects(true); + + int status = connection.getResponseCode(); + if (status >= 400) { + throw new IOException("Failed to download package: HTTP " + status); + } + + String contentType = Optional.ofNullable(connection.getContentType()).orElse(""); + String fileName = resolveRemoteFileName(connection, packageUrl, contentType); + Path path = tempFile + ? Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName)) + : buildRemoteCachePath(packageUrl, fileName, contentType); + + if (!tempFile && Files.exists(path)) { + return new RemotePackage(path, fileName, contentType); + } + + try (InputStream in = connection.getInputStream()) { + Files.copy(in, path, StandardCopyOption.REPLACE_EXISTING); + } + return new RemotePackage(path, fileName, contentType); + } + + private Path buildRemoteCachePath(String packageUrl, String fileName, String contentType) throws IOException { + Path dir = Paths.get(uploadDir, "apk", "remote"); + Files.createDirectories(dir); + String safeName = sanitizeFileName(fileName); + String cacheName = sha256Hex(packageUrl) + "_" + safeName; + if (!cacheName.contains(".") && hasText(contentType)) { + cacheName = cacheName + suffixByContentType(contentType); + } + return dir.resolve(cacheName); + } + + private String resolveRemoteFileName(HttpURLConnection connection, String packageUrl, String contentType) { + String fileName = null; + String disposition = connection.getHeaderField("Content-Disposition"); + if (hasText(disposition)) { + try { + fileName = ContentDisposition.parse(disposition).getFilename(); + } catch (Exception ignored) { + fileName = null; + } + if (!hasText(fileName)) { + int idx = disposition.toLowerCase(Locale.ROOT).indexOf("filename="); + if (idx >= 0) { + fileName = disposition.substring(idx + 9).replace("\"", "").trim(); + } + } + } + if (!hasText(fileName)) { + String pathName = Paths.get(URI.create(packageUrl).getPath()).getFileName() != null + ? Paths.get(URI.create(packageUrl).getPath()).getFileName().toString() + : null; + fileName = hasText(pathName) ? pathName : "downloaded"; + } + if (!fileName.contains(".")) { + fileName = fileName + suffixByContentType(contentType); + } + return fileName; + } + + private String suffixByContentType(String contentType) { + String lower = Optional.ofNullable(contentType).orElse("").toLowerCase(Locale.ROOT); + if (lower.contains("android.package-archive")) return ".apk"; + if (lower.contains("ipa")) return ".ipa"; + if (lower.contains("zip")) return ".zip"; + return ".bin"; + } + + private String sanitizeFileName(String fileName) { + String safe = Optional.ofNullable(fileName).orElse("downloaded"); + return safe.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + private Integer parseInteger(String value) { if (value == null || value.isBlank()) return null; try { @@ -246,5 +371,31 @@ public class UpdateAssetService { return value == null || value.isBlank() ? null : value.trim(); } + private AppPackageInspectResult fallbackInspectResult(String source) { + String fileName = source; + try { + if (source != null && (source.startsWith("http://") || source.startsWith("https://"))) { + fileName = Optional.ofNullable(Paths.get(URI.create(source).getPath()).getFileName()) + .map(Path::toString) + .orElse(source); + } + } catch (Exception ignored) { + fileName = source; + } + return new AppPackageInspectResult( + platformFromFileName(fileName), + null, + null, + null, + fileName, + false); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + public record StoredRnBundle(String bundlePath, String md5) {} + + private record RemotePackage(Path path, String fileName, String contentType) {} }