feat(sdk): 添加鸿蒙SDK核心功能模块

- 实现SDKContext用于配置管理和数据持久化存储
- 定义完整的类型系统包括消息、用户、群组等接口
- 集成更新SDK支持原生应用和RN热更新检查
- 提供统一的XuqmSDK入口类和模块导出
- 编写详细的开发文档和使用示例
这个提交包含在:
XuqmGroup 2026-04-29 19:08:13 +08:00
父节点 f5a1eb4470
当前提交 93311f1739
共有 20 个文件被更改,包括 1459 次插入45 次删除

查看文件

@ -144,7 +144,7 @@
| 方法 | 路径 | 鉴权 | 说明 | | 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------| |------|------|------|------|
| GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | | 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 版本 | | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 |
| GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 |
| GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK |
@ -163,13 +163,17 @@
- 这里的 `appId``appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。 - 这里的 `appId``appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。
- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 - `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/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` - 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload``POST /api/v1/updates/app/inspect`
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。 - 如果远程包地址暂时不可读,`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 自动发版 ## update-sdk 自动发版
三个移动端 SDK 现在都支持通过脚本完成“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”的流程 Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”完成完整发布流程;iOS / Harmony 版本只记录版本号、市场跳转页和发布信息,不要求上传安装包
脚本支持的公共参数: 脚本支持的公共参数:
@ -198,7 +202,7 @@
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。
6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
租户平台里的“发版默认配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 租户平台里的“发配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。
## curl 示例 ## curl 示例

查看文件

@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.service.AppStoreService; import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ConnectivityValidationService;
import com.xuqm.update.service.StoreSubmissionService; import com.xuqm.update.service.StoreSubmissionService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -24,10 +25,14 @@ public class AppStoreController {
private final AppStoreService storeService; private final AppStoreService storeService;
private final StoreSubmissionService submissionService; 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.storeService = storeService;
this.submissionService = submissionService; this.submissionService = submissionService;
this.connectivityValidationService = connectivityValidationService;
} }
// Store credential config // Store credential config
@ -50,6 +55,9 @@ public class AppStoreController {
String configJson = body.get("configJson") instanceof String s ? s : null; String configJson = body.get("configJson") instanceof String s ? s : null;
boolean enabled = !Boolean.FALSE.equals(body.get("enabled")); 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( return ResponseEntity.ok(ApiResponse.success(
storeService.saveConfig(appId, storeType, configJson, enabled))); storeService.saveConfig(appId, storeType, configJson, enabled)));
} }
@ -106,9 +114,23 @@ public class AppStoreController {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<String> storeTypes = body != null ? (List<String>) body.get("storeTypes") : null; List<String> storeTypes = body != null ? (List<String>) 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, AppVersionEntity v = storeService.markSubmitted(versionId,
storeTypes != null ? storeTypes : List.of()); storeTypes != null ? storeTypes : List.of(),
submissionService.executeSubmitAsync(versionId); submitMode,
scheduledAt,
autoPublishAfterReview);
String normalizedMode = submitMode == null ? "MANUAL" : submitMode.trim().toUpperCase();
if (!"SCHEDULED".equals(normalizedMode)) {
submissionService.executeSubmitAsync(versionId);
}
return ResponseEntity.ok(ApiResponse.success(v)); return ResponseEntity.ok(ApiResponse.success(v));
} }
@ -131,4 +153,18 @@ public class AppStoreController {
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
storeService.updateStoreReview(versionId, storeType, state))); storeService.updateStoreReview(versionId, storeType, state)));
} }
private void validateReviewWebhook(String configJson) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> 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);
}
}
} }

查看文件

@ -16,6 +16,8 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService;
@RestController @RestController
@RequestMapping("/api/v1/updates") @RequestMapping("/api/v1/updates")
@ -25,10 +27,17 @@ public class AppVersionController {
private final AppVersionRepository versionRepository; private final AppVersionRepository versionRepository;
private final UpdateAssetService updateAssetService; 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.versionRepository = versionRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
} }
@GetMapping("/app/check") @GetMapping("/app/check")
@ -46,6 +55,12 @@ public class AppVersionController {
} }
AppVersionEntity v = latest.get(); 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( return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", true, "needsUpdate", true,
"versionName", v.getVersionName(), "versionName", v.getVersionName(),
@ -53,8 +68,8 @@ public class AppVersionController {
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
"forceUpdate", v.isForceUpdate(), "forceUpdate", v.isForceUpdate(),
"appStoreUrl", v.getAppStoreUrl() != null ? v.getAppStoreUrl() : "", "appStoreUrl", appStoreJumpUrl,
"marketUrl", v.getMarketUrl() != null ? v.getMarketUrl() : "" "marketUrl", harmonyJumpUrl
))); )));
} }
@ -74,6 +89,7 @@ public class AppVersionController {
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview, @RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
@RequestParam(defaultValue = "false") boolean publishImmediately, @RequestParam(defaultValue = "false") boolean publishImmediately,
@RequestParam(required = false) String packageName, @RequestParam(required = false) String packageName,
@RequestParam(required = false) String expectedPackageName,
@RequestParam(required = false) String appStoreUrl, @RequestParam(required = false) String appStoreUrl,
@RequestParam(required = false) String marketUrl) throws Exception { @RequestParam(required = false) String marketUrl) throws Exception {
@ -92,29 +108,29 @@ public class AppVersionController {
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) { if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package"); 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())) { if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) {
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID and IOS releases"); throw new IllegalArgumentException("packageName does not match current app packageName");
} }
if (platform == AppVersionEntity.Platform.HARMONY && !hasText(marketUrl)) { if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
throw new IllegalArgumentException("marketUrl is required for HARMONY releases"); 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(); AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
entity.setPlatform(platform); entity.setPlatform(platform);
entity.setVersionName(resolvedVersionName); entity.setVersionName(resolvedVersionName);
entity.setVersionCode(resolvedVersionCode); entity.setVersionCode(resolvedVersionCode);
entity.setDownloadUrl(platform == AppVersionEntity.Platform.HARMONY entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID
? null ? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))
: (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))); : null);
entity.setChangeLog(changeLog); entity.setChangeLog(changeLog);
entity.setForceUpdate(forceUpdate); entity.setForceUpdate(forceUpdate);
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
entity.setStoreSubmitMode("MANUAL");
entity.setStoreSubmitScheduledAt(null);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
} }
@ -122,8 +138,16 @@ public class AppVersionController {
entity.setStoreSubmitTargets(storeSubmitTargets); entity.setStoreSubmitTargets(storeSubmitTargets);
entity.setAutoPublishAfterReview(autoPublishAfterReview); entity.setAutoPublishAfterReview(autoPublishAfterReview);
entity.setPackageName(resolvedPackageName); entity.setPackageName(resolvedPackageName);
entity.setAppStoreUrl(appStoreUrl); entity.setAppStoreUrl(hasText(appStoreUrl)
entity.setMarketUrl(marketUrl); ? 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) { if (publishImmediately) {
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
entity.setGrayEnabled(false); entity.setGrayEnabled(false);
@ -143,11 +167,29 @@ public class AppVersionController {
} }
@PostMapping("/app/{id}/publish") @PostMapping("/app/{id}/publish")
public ResponseEntity<ApiResponse<AppVersionEntity>> publish(@PathVariable String id) { public ResponseEntity<ApiResponse<AppVersionEntity>> publish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); 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.setGrayEnabled(false);
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
} }
@ -161,10 +203,30 @@ public class AppVersionController {
@PostMapping("/app/{id}/gray") @PostMapping("/app/{id}/gray")
public ResponseEntity<ApiResponse<AppVersionEntity>> gray( public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) throws Exception {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled"))); boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); 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<String> 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); entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
} }
@ -179,4 +241,39 @@ public class AppVersionController {
private boolean hasText(String value) { private boolean hasText(String value) {
return value != null && !value.isBlank(); return value != null && !value.isBlank();
} }
private List<String> 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<String> values) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values);
} catch (Exception e) {
return "[]";
}
}
} }

查看文件

@ -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<ApiResponse<AppPublishConfigEntity>> getConfig(@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(publishConfigService.getConfig(appId)));
}
@PutMapping("/publish/config")
public ResponseEntity<ApiResponse<AppPublishConfigEntity>> saveConfig(
@RequestParam String appId,
@RequestBody Map<String, Object> body) {
validateCallbacks(body);
return ResponseEntity.ok(ApiResponse.success(publishConfigService.saveConfig(appId, body)));
}
@GetMapping("/gray/members")
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> 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<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> syncMembers(
@RequestParam String appId) {
return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appId)));
}
@PostMapping("/gray/members/import")
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> importMembers(
@RequestParam String appId,
@RequestBody String payload) {
return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appId, payload)));
}
private void validateCallbacks(Map<String, Object> 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, "灰度成员同步回调");
}
}
}

查看文件

@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.model.RnBundleInspectResult;
import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.PublishConfigService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -22,13 +23,17 @@ public class RnBundleController {
private final RnBundleRepository bundleRepository; private final RnBundleRepository bundleRepository;
private final UpdateAssetService updateAssetService; private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
@Value("${update.base-url:https://update.dev.xuqinmin.com}") @Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl; private String baseUrl;
public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) { public RnBundleController(RnBundleRepository bundleRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService) {
this.bundleRepository = bundleRepository; this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
} }
@GetMapping("/update/check") @GetMapping("/update/check")
@ -52,6 +57,7 @@ public class RnBundleController {
boolean needsUpdate = !b.getVersion().equals(currentVersion); boolean needsUpdate = !b.getVersion().equals(currentVersion);
return ResponseEntity.ok(ApiResponse.success(Map.of( return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", needsUpdate, "needsUpdate", needsUpdate,
"bundleVersion", parseBundleVersion(b.getVersion()),
"latestVersion", b.getVersion(), "latestVersion", b.getVersion(),
"downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId, "downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId,
"md5", b.getMd5(), "md5", b.getMd5(),
@ -97,6 +103,10 @@ public class RnBundleController {
entity.setPackageName(resolvedPackageName); entity.setPackageName(resolvedPackageName);
entity.setNote(note); entity.setNote(note);
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setPublishMode("MANUAL");
entity.setScheduledPublishAt(null);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
entity.setCreatedAt(LocalDateTime.now()); entity.setCreatedAt(LocalDateTime.now());
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
} }
@ -124,11 +134,28 @@ public class RnBundleController {
} }
@PostMapping("/{id}/publish") @PostMapping("/{id}/publish")
public ResponseEntity<ApiResponse<RnBundleEntity>> publish(@PathVariable String id) { public ResponseEntity<ApiResponse<RnBundleEntity>> publish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); 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.setGrayEnabled(false);
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
} }
@ -142,10 +169,30 @@ public class RnBundleController {
@PostMapping("/{id}/gray") @PostMapping("/{id}/gray")
public ResponseEntity<ApiResponse<RnBundleEntity>> gray( public ResponseEntity<ApiResponse<RnBundleEntity>> gray(
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) throws Exception {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled"))); boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); 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<String> 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); entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
} }
@ -173,4 +220,50 @@ public class RnBundleController {
return null; 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<String> values) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(values == null ? List.of() : values);
} catch (Exception e) {
return "[]";
}
}
private List<String> 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();
}
} }

查看文件

@ -59,6 +59,9 @@ public class UnifiedReleaseController {
List<AppVersionEntity> appVersions = new ArrayList<>(); List<AppVersionEntity> appVersions = new ArrayList<>();
for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) { for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) {
MultipartFile file = multipartRequest.getFile(item.fileKey()); 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(); AppVersionEntity entity = new AppVersionEntity();
entity.setId(UUID.randomUUID().toString()); entity.setId(UUID.randomUUID().toString());
entity.setAppId(appId); entity.setAppId(appId);
@ -72,7 +75,9 @@ public class UnifiedReleaseController {
entity.setMarketUrl(item.marketUrl()); entity.setMarketUrl(item.marketUrl());
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now()); 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()) { if (item.publishImmediately()) {
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
entity.setGrayEnabled(false); entity.setGrayEnabled(false);

查看文件

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

查看文件

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

查看文件

@ -13,12 +13,13 @@ public class AppStoreConfigEntity {
* Supported distribution channels. * Supported distribution channels.
* Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY * Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY
* iOS: APP_STORE * iOS: APP_STORE
* Harmony: HARMONY_APP
*/ */
public enum StoreType { 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() { 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. * Store-specific credentials stored as a flat JSON object.
* Every store config also carries its own marketUrl / jump page.
* *
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."} * HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
* MI: {"username":"...","privateKey":"..."} * MI: {"username":"...","privateKey":"..."}
* OPPO: {"clientId":"...","clientSecret":"..."} * OPPO: {"clientId":"...","clientSecret":"..."}
* VIVO: {"accessKey":"...","accessSecret":"..."} * VIVO: {"accessKey":"...","accessSecret":"..."}
* GOOGLE_PLAY: {"serviceAccountJson":"..."} * GOOGLE_PLAY: {"serviceAccountJson":"..."}
* APP_STORE: {"teamId":"...","keyId":"...","privateKey":"...","bundleId":"..."} * APP_STORE: {"marketUrl":"..."}
* HARMONY_APP: {"marketUrl":"..."}
* REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."}
*/ */
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String configJson; private String configJson;

查看文件

@ -46,6 +46,11 @@ public class AppVersionEntity {
@Column(nullable = false, length = 16) @Column(nullable = false, length = 16)
private PublishStatus publishStatus; private PublishStatus publishStatus;
@Column(length = 16)
private String storeSubmitMode = "MANUAL";
private LocalDateTime storeSubmitScheduledAt;
@Column(length = 256) @Column(length = 256)
private String appStoreUrl; private String appStoreUrl;
@ -83,6 +88,14 @@ public class AppVersionEntity {
@Column(length = 512) @Column(length = 512)
private String webhookUrl; 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 */ /** App package name / bundle identifier, e.g. com.example.myapp */
@Column(length = 256) @Column(length = 256)
private String packageName; private String packageName;
@ -117,6 +130,12 @@ public class AppVersionEntity {
public PublishStatus getPublishStatus() { return publishStatus; } public PublishStatus getPublishStatus() { return publishStatus; }
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = 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 String getAppStoreUrl() { return appStoreUrl; }
public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; } public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; }
@ -149,4 +168,10 @@ public class AppVersionEntity {
public String getWebhookUrl() { return webhookUrl; } public String getWebhookUrl() { return webhookUrl; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = 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; }
} }

查看文件

@ -50,12 +50,23 @@ public class RnBundleEntity {
@Column(nullable = false, length = 16) @Column(nullable = false, length = 16)
private PublishStatus publishStatus; private PublishStatus publishStatus;
@Column(length = 16)
private String publishMode = "MANUAL";
private LocalDateTime scheduledPublishAt;
@Column(nullable = false) @Column(nullable = false)
private boolean grayEnabled = false; private boolean grayEnabled = false;
@Column(nullable = false) @Column(nullable = false)
private int grayPercent = 0; private int grayPercent = 0;
@Column(length = 16)
private String grayMode = "PERCENT";
@Column(columnDefinition = "TEXT")
private String grayMemberIds;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -92,12 +103,24 @@ public class RnBundleEntity {
public PublishStatus getPublishStatus() { return publishStatus; } public PublishStatus getPublishStatus() { return publishStatus; }
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = 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 boolean isGrayEnabled() { return grayEnabled; }
public void setGrayEnabled(boolean grayEnabled) { this.grayEnabled = grayEnabled; } public void setGrayEnabled(boolean grayEnabled) { this.grayEnabled = grayEnabled; }
public int getGrayPercent() { return grayPercent; } public int getGrayPercent() { return grayPercent; }
public void setGrayPercent(int grayPercent) { this.grayPercent = 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 LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
} }

查看文件

@ -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<AppGrayMemberEntity, String> {
List<AppGrayMemberEntity> findByAppIdOrderByGroupNameAscNameAscUserIdAsc(String appId);
}

查看文件

@ -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<AppPublishConfigEntity, String> {
Optional<AppPublishConfigEntity> findByAppId(String appId);
}

查看文件

@ -14,4 +14,7 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore( List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore(
AppVersionEntity.PublishStatus status, LocalDateTime before); AppVersionEntity.PublishStatus status, LocalDateTime before);
List<AppVersionEntity> findByStoreSubmitModeAndStoreSubmitScheduledAtBefore(
String storeSubmitMode, LocalDateTime before);
} }

查看文件

@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.time.LocalDateTime;
public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String> { public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String> {
List<RnBundleEntity> findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc( List<RnBundleEntity> findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc(
@ -13,4 +14,7 @@ public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String
List<RnBundleEntity> findByAppIdOrderByCreatedAtDesc(String appId); List<RnBundleEntity> findByAppIdOrderByCreatedAtDesc(String appId);
Optional<RnBundleEntity> findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc( Optional<RnBundleEntity> findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc(
String appId, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status); String appId, String moduleId, RnBundleEntity.Platform platform, RnBundleEntity.PublishStatus status);
List<RnBundleEntity> findByPublishStatusAndScheduledPublishAtBefore(
RnBundleEntity.PublishStatus status, LocalDateTime before);
} }

查看文件

@ -4,8 +4,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.repository.AppStoreConfigRepository; import com.xuqm.update.repository.AppStoreConfigRepository;
import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.repository.RnBundleRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -27,10 +29,14 @@ public class AppStoreService {
private final AppStoreConfigRepository configRepo; private final AppStoreConfigRepository configRepo;
private final AppVersionRepository versionRepo; 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.configRepo = configRepo;
this.versionRepo = versionRepo; this.versionRepo = versionRepo;
this.rnBundleRepository = rnBundleRepository;
} }
// Store config CRUD // 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 * machine (it has the APK/IPA file). This endpoint records the intent and provides the
* credentials the script needs. * credentials the script needs.
*/ */
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception { public AppVersionEntity markSubmitted(String versionId,
List<String> storeTypes,
String submitMode,
LocalDateTime scheduledAt,
Boolean autoPublishAfterReview) throws Exception {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, String> reviewMap = new LinkedHashMap<>(); Map<String, String> reviewMap = new LinkedHashMap<>();
@ -79,9 +89,18 @@ public class AppStoreService {
} }
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes)); v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); 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); return versionRepo.save(v);
} }
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
return markSubmitted(versionId, storeTypes, "MANUAL", null, null);
}
/** /**
* Fetch enabled store credentials for use by the release script. * Fetch enabled store credentials for use by the release script.
* Returns a map of storeType -> configJson (as parsed map, not raw string). * Returns a map of storeType -> configJson (as parsed map, not raw string).
@ -90,6 +109,9 @@ public class AppStoreService {
List<AppStoreConfigEntity> configs = configRepo.findByAppIdAndEnabled(appId, true); List<AppStoreConfigEntity> configs = configRepo.findByAppIdAndEnabled(appId, true);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
for (AppStoreConfigEntity cfg : configs) { for (AppStoreConfigEntity cfg : configs) {
if (cfg.getStoreType() == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) {
continue;
}
Map<String, Object> parsed = cfg.getConfigJson() != null Map<String, Object> parsed = cfg.getConfigJson() != null
? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {}) ? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {})
: Map.of(); : Map.of();
@ -98,6 +120,26 @@ public class AppStoreService {
return result; return result;
} }
public Map<String, String> 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 // Review status webhook
/** /**
@ -132,15 +174,34 @@ public class AppStoreService {
AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now()); AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now());
for (AppVersionEntity v : due) { for (AppVersionEntity v : due) {
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
v.setScheduledPublishAt(null);
versionRepo.save(v); versionRepo.save(v);
log.info("Scheduled publish executed for version {}", v.getId()); log.info("Scheduled publish executed for version {}", v.getId());
} }
List<RnBundleEntity> 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 // Webhook delivery
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) { private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) {
String url = v.getWebhookUrl(); String url = v.getWebhookUrl();
if (url == null || url.isBlank()) {
try {
Map<String, String> shared = getReviewWebhookConfig(v.getAppId());
url = shared.get("webhookUrl");
} catch (Exception ignored) {
url = null;
}
}
if (url == null || url.isBlank()) return; if (url == null || url.isBlank()) return;
try { try {
@ -154,9 +215,11 @@ public class AppStoreService {
"publishStatus", v.getPublishStatus().name(), "publishStatus", v.getPublishStatus().name(),
"timestamp", System.currentTimeMillis() "timestamp", System.currentTimeMillis()
)); ));
String secret = resolveWebhookSecret(v.getAppId());
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url)) .uri(URI.create(url))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("X-Xuqm-Webhook-Secret", secret == null ? "" : secret)
.POST(HttpRequest.BodyPublishers.ofString(body)) .POST(HttpRequest.BodyPublishers.ofString(body))
.build(); .build();
http.sendAsync(request, HttpResponse.BodyHandlers.discarding()) http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
@ -179,4 +242,25 @@ public class AppStoreService {
return targets.stream().allMatch(t -> return targets.stream().allMatch(t ->
AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(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<String, Object> config = mapper.readValue(configJson, new TypeReference<>() {});
Object marketUrl = config.get("marketUrl");
return marketUrl == null ? "" : marketUrl.toString();
} catch (Exception e) {
return "";
}
}
} }

查看文件

@ -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<String, Object> 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<String> 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);
}
}
}

查看文件

@ -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<String, Object> 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<GrayMemberGroupView> 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<AppGrayMemberEntity> 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<String, List<GrayMemberView>> 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<GrayMemberGroupView> 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<String, Object> requestBody = Map.of(
"appId", appId,
"timestamp", System.currentTimeMillis(),
"action", "sync"
);
ResponseEntity<String> 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<String> resolveGrayMembers(String appId, Map<String, Object> requestBody) {
JsonNode config = getConfigNode(appId);
String url = config.path("graySelectCallbackUrl").asText("");
if (url == null || url.isBlank()) {
throw new IllegalStateException("graySelectCallbackUrl is not configured");
}
Map<String, Object> payload = new LinkedHashMap<>();
if (requestBody != null) {
payload.putAll(requestBody);
}
payload.put("appId", appId);
payload.put("timestamp", System.currentTimeMillis());
ResponseEntity<String> 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<GrayMemberGroupView> replaceGrayMembers(String appId, String responseBody) {
List<GrayMemberGroupPayload> groups = parseGroups(responseBody);
if (groups == null) {
throw new IllegalStateException("Invalid gray member payload");
}
grayMemberRepository.deleteAll(
grayMemberRepository.findByAppIdOrderByGroupNameAscNameAscUserIdAsc(appId));
List<AppGrayMemberEntity> 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<String> 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<String> 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<String> 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<String> 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<GrayMemberGroupPayload> parseGroups(String responseBody) {
if (responseBody == null || responseBody.isBlank()) {
return null;
}
try {
JsonNode root = objectMapper.readTree(responseBody);
if (root.path("groups").isArray()) {
List<GrayMemberGroupPayload> groups = new ArrayList<>();
for (JsonNode group : root.path("groups")) {
groups.add(parseGroup(group));
}
return groups;
}
if (root.isArray()) {
List<GrayMemberGroupPayload> 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<GrayMemberPayload> members = parseMembers(node.path("members"));
return new GrayMemberGroupPayload(groupName, members);
}
private List<GrayMemberPayload> parseMembers(JsonNode node) {
if (node == null || !node.isArray()) {
return List.of();
}
List<GrayMemberPayload> 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<GrayMemberPayload> safeMembers(List<GrayMemberPayload> 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<GrayMemberView> members) {}
public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {}
private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {}
private record GrayMemberPayload(String userId, String name, String extraJson) {}
}

查看文件

@ -1,6 +1,7 @@
package com.xuqm.update.service; package com.xuqm.update.service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity; import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity; 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.core.io.FileSystemResource;
import org.springframework.http.*; import org.springframework.http.*;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.math.BigInteger;
import java.net.URI; import java.net.URI;
import java.nio.file.Paths; 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.*; import java.util.*;
/** /**
@ -68,6 +79,12 @@ public class StoreSubmissionService {
AppVersionEntity v = versionRepo.findById(versionId).orElse(null); AppVersionEntity v = versionRepo.findById(versionId).orElse(null);
if (v == null) { log.error("Version not found: {}", versionId); return; } 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<String> targets = parseTargets(v.getStoreSubmitTargets()); List<String> targets = parseTargets(v.getStoreSubmitTargets());
if (targets.isEmpty()) { log.warn("No store targets for version {}", versionId); return; } 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<AppVersionEntity> due = versionRepo.findByStoreSubmitModeAndStoreSubmitScheduledAtBefore(
"SCHEDULED", java.time.LocalDateTime.now());
for (AppVersionEntity v : due) {
executeSubmitAsync(v.getId());
}
}
// Dispatch // Dispatch
private void submitToStore(String storeType, AppVersionEntity v, File file, private void submitToStore(String storeType, AppVersionEntity v, File file,
@ -111,6 +137,7 @@ public class StoreSubmissionService {
case "VIVO" -> submitToVivo(v, file, creds); case "VIVO" -> submitToVivo(v, file, creds);
case "APP_STORE" -> submitToAppStore(v, file, creds); case "APP_STORE" -> submitToAppStore(v, file, creds);
case "GOOGLE_PLAY" -> submitToGooglePlay(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); default -> throw new IllegalArgumentException("Unknown store: " + storeType);
} }
} }
@ -262,23 +289,54 @@ public class StoreSubmissionService {
// API: https://dev.mi.com/distribute/doc/details?pId=1134 // API: https://dev.mi.com/distribute/doc/details?pId=1134
private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) { private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) {
// Xiaomi submission is intentionally non-blocking for now. try {
// Keep the release flow moving even when one store channel still needs manual completion. String account = resolveMiAccount(creds);
log.warn("MI store submission not yet implemented - leaving review state as UNDER_REVIEW for manual completion"); 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 // OPPO Software Store
// API: https://open.oppomobile.com/new/developmentDoc/info?id=11119 // API: https://open.oppomobile.com/new/developmentDoc/info?id=11119
private void submitToOppo(AppVersionEntity v, File file, Map<String, String> creds) { private void submitToOppo(AppVersionEntity v, File file, Map<String, String> 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<String, String> 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 // vivo App Store
// API: https://dev.vivo.com.cn/documentCenter/doc/326 // API: https://dev.vivo.com.cn/documentCenter/doc/326
private void submitToVivo(AppVersionEntity v, File file, Map<String, String> creds) { private void submitToVivo(AppVersionEntity v, File file, Map<String, String> 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 // Apple App Store Connect
@ -296,6 +354,351 @@ public class StoreSubmissionService {
// Utilities // Utilities
private JsonNode miGetAppInfo(String account,
String packageName,
String publicKey,
String privateKey) throws Exception {
Map<String, Object> requestData = new LinkedHashMap<>();
requestData.put("userName", account);
requestData.put("packageName", packageName);
Map<String, Object> sig = new LinkedHashMap<>();
sig.put("password", privateKey);
sig.put("sig", List.of(Map.of(
"name", "RequestData",
"hash", md5Hex(asJsonString(requestData))
)));
MultiValueMap<String, Object> 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<String> 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<String, Object> 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<String, Object> 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<String, Object> body = new LinkedMultiValueMap<>();
body.add("apk", new FileSystemResource(apkFile));
body.add("RequestData", asJsonString(requestData));
body.add("SIG", rsaEncryptHex(asJsonString(sig), publicKey));
ResponseEntity<String> 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<String, String> 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<String, String> params = Map.of("pkg_name", packageName);
String requestUrl = oppoRequestUrl("https://oop-openapi-cn.heytapmobi.com/resource/v1/app/info", params, token, true, clientSecret);
ResponseEntity<String> 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<String, String> 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<String> 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<String, String> uploadUrl,
String token,
File file,
String clientSecret) throws Exception {
Map<String, String> params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", ""));
String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, false, clientSecret);
MultiValueMap<String, Object> 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<String> 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<String, String> 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<String, String> body = new LinkedMultiValueMap<>();
params.forEach(body::add);
ResponseEntity<String> 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<String> 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<String, String> params,
String token,
boolean paramsAppendQuery,
String clientSecret) {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
Map<String, String> 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<String, String> paramsMap) {
List<String> keys = new ArrayList<>(paramsMap.keySet());
Collections.sort(keys);
List<String> 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<String> 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<String, String> params = Map.of(
"packageName", packageName,
"fileMd5", md5Hex(file)
);
String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.upload.apk.app", params);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource(file));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> 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<String, String> 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<String> 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<String, String> originParams) {
Map<String, String> 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) { private File resolveLocalFile(String downloadUrl) {
if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null"); if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null");
String path = URI.create(downloadUrl).getPath(); String path = URI.create(downloadUrl).getPath();

查看文件

@ -1,5 +1,7 @@
package com.xuqm.update.service; 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.AppPackageInspectResult;
import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.model.RnBundleInspectResult;
import net.dongliu.apk.parser.ApkFile; import net.dongliu.apk.parser.ApkFile;
@ -40,6 +42,7 @@ import org.w3c.dom.NodeList;
public class UpdateAssetService { public class UpdateAssetService {
private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class); private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class);
private final ObjectMapper objectMapper;
@Value("${update.upload-dir:/tmp/xuqm-update}") @Value("${update.upload-dir:/tmp/xuqm-update}")
private String uploadDir; private String uploadDir;
@ -47,6 +50,10 @@ public class UpdateAssetService {
@Value("${update.base-url:https://update.dev.xuqinmin.com}") @Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl; private String baseUrl;
public UpdateAssetService(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public String storeAppPackage(MultipartFile apkFile) throws IOException { public String storeAppPackage(MultipartFile apkFile) throws IOException {
if (apkFile == null || apkFile.isEmpty()) { if (apkFile == null || apkFile.isEmpty()) {
return null; return null;
@ -109,6 +116,20 @@ public class UpdateAssetService {
if (bundle == null || bundle.isEmpty()) { if (bundle == null || bundle.isEmpty()) {
return new RnBundleInspectResult(null, null, null, null, null, fileName, false); 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); return inspectRnBundleName(fileName);
} }
@ -116,7 +137,7 @@ public class UpdateAssetService {
if (bundle == null || bundle.isEmpty()) { if (bundle == null || bundle.isEmpty()) {
throw new IllegalArgumentException("bundle file is required"); 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); Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
Files.createDirectories(dir); Files.createDirectories(dir);
Path dest = dir.resolve(filename); Path dest = dir.resolve(filename);
@ -209,6 +230,34 @@ public class UpdateAssetService {
false); 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<String, String> parsePlistXml(String xml) throws Exception { private Map<String, String> parsePlistXml(String xml) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(false); factory.setNamespaceAware(false);
@ -270,6 +319,24 @@ public class UpdateAssetService {
return idx > 0 ? fileName.substring(idx) : ".tmp"; 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 { private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection(); HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection();
connection.setConnectTimeout(15_000); connection.setConnectTimeout(15_000);
@ -371,6 +438,19 @@ public class UpdateAssetService {
return value == null || value.isBlank() ? null : value.trim(); 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) { private AppPackageInspectResult fallbackInspectResult(String source) {
String fileName = source; String fileName = source;
try { try {