feat(sdk): 添加鸿蒙SDK核心功能模块
- 实现SDKContext用于配置管理和数据持久化存储 - 定义完整的类型系统包括消息、用户、群组等接口 - 集成更新SDK支持原生应用和RN热更新检查 - 提供统一的XuqmSDK入口类和模块导出 - 编写详细的开发文档和使用示例
这个提交包含在:
父节点
f5a1eb4470
当前提交
93311f1739
@ -144,7 +144,7 @@
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 |
|
||||
| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android / iOS 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,Harmony 版本仅保存市场链接,不提供本地安装包下载 |
|
||||
| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,iOS / Harmony 仅记录版本号与市场跳转信息,`marketUrl` 可选,不要求本地安装包;可附带 `expectedPackageName` 作为当前应用包名守卫 |
|
||||
| POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 |
|
||||
| GET | `/api/v1/updates/app/list` | 是 | App 版本列表 |
|
||||
| GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK |
|
||||
@ -163,13 +163,17 @@
|
||||
|
||||
- 这里的 `appId` 按 `appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。
|
||||
- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。
|
||||
- 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
|
||||
- `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。
|
||||
- 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload` 和 `POST /api/v1/updates/app/inspect`。
|
||||
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。
|
||||
- RN bundle 建议打成 zip 后再上传,zip 内至少包含 `rn-manifest.json`、bundle 文件和资源文件;update-service 会优先从 manifest 自动读取 `moduleId`、`version` / `bundleVersion`、`minCommonVersion` 和 `packageName`。
|
||||
- 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。
|
||||
- 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。
|
||||
|
||||
## update-sdk 自动发版
|
||||
|
||||
三个移动端 SDK 现在都支持通过脚本完成“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”的流程。
|
||||
Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传 -> 选择发布时间/市场/回调”完成完整发布流程;iOS / Harmony 版本只记录版本号、市场跳转页和发布信息,不要求上传安装包。
|
||||
|
||||
脚本支持的公共参数:
|
||||
|
||||
@ -198,7 +202,7 @@
|
||||
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。
|
||||
6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
|
||||
|
||||
租户平台里的“发版默认配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。
|
||||
租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。
|
||||
|
||||
## curl 示例
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse;
|
||||
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||
import com.xuqm.update.entity.AppVersionEntity;
|
||||
import com.xuqm.update.service.AppStoreService;
|
||||
import com.xuqm.update.service.ConnectivityValidationService;
|
||||
import com.xuqm.update.service.StoreSubmissionService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -24,10 +25,14 @@ public class AppStoreController {
|
||||
|
||||
private final AppStoreService storeService;
|
||||
private final StoreSubmissionService submissionService;
|
||||
private final ConnectivityValidationService connectivityValidationService;
|
||||
|
||||
public AppStoreController(AppStoreService storeService, StoreSubmissionService submissionService) {
|
||||
public AppStoreController(AppStoreService storeService,
|
||||
StoreSubmissionService submissionService,
|
||||
ConnectivityValidationService connectivityValidationService) {
|
||||
this.storeService = storeService;
|
||||
this.submissionService = submissionService;
|
||||
this.connectivityValidationService = connectivityValidationService;
|
||||
}
|
||||
|
||||
// ── Store credential config ──────────────────────────────────────────────
|
||||
@ -50,6 +55,9 @@ public class AppStoreController {
|
||||
|
||||
String configJson = body.get("configJson") instanceof String s ? s : null;
|
||||
boolean enabled = !Boolean.FALSE.equals(body.get("enabled"));
|
||||
if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK && configJson != null && !configJson.isBlank()) {
|
||||
validateReviewWebhook(configJson);
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
storeService.saveConfig(appId, storeType, configJson, enabled)));
|
||||
}
|
||||
@ -106,9 +114,23 @@ public class AppStoreController {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<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,
|
||||
storeTypes != null ? storeTypes : List.of());
|
||||
storeTypes != null ? storeTypes : List.of(),
|
||||
submitMode,
|
||||
scheduledAt,
|
||||
autoPublishAfterReview);
|
||||
String normalizedMode = submitMode == null ? "MANUAL" : submitMode.trim().toUpperCase();
|
||||
if (!"SCHEDULED".equals(normalizedMode)) {
|
||||
submissionService.executeSubmitAsync(versionId);
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(v));
|
||||
}
|
||||
|
||||
@ -131,4 +153,18 @@ public class AppStoreController {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
storeService.updateStoreReview(versionId, storeType, state)));
|
||||
}
|
||||
|
||||
private void validateReviewWebhook(String configJson) {
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<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.UUID;
|
||||
import com.xuqm.update.service.UpdateAssetService;
|
||||
import com.xuqm.update.service.PublishConfigService;
|
||||
import com.xuqm.update.service.AppStoreService;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/updates")
|
||||
@ -25,10 +27,17 @@ public class AppVersionController {
|
||||
|
||||
private final AppVersionRepository versionRepository;
|
||||
private final UpdateAssetService updateAssetService;
|
||||
private final PublishConfigService publishConfigService;
|
||||
private final AppStoreService appStoreService;
|
||||
|
||||
public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService) {
|
||||
public AppVersionController(AppVersionRepository versionRepository,
|
||||
UpdateAssetService updateAssetService,
|
||||
PublishConfigService publishConfigService,
|
||||
AppStoreService appStoreService) {
|
||||
this.versionRepository = versionRepository;
|
||||
this.updateAssetService = updateAssetService;
|
||||
this.publishConfigService = publishConfigService;
|
||||
this.appStoreService = appStoreService;
|
||||
}
|
||||
|
||||
@GetMapping("/app/check")
|
||||
@ -46,6 +55,12 @@ public class AppVersionController {
|
||||
}
|
||||
|
||||
AppVersionEntity v = latest.get();
|
||||
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
|
||||
? v.getAppStoreUrl()
|
||||
: appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
||||
String harmonyJumpUrl = hasText(v.getMarketUrl())
|
||||
? v.getMarketUrl()
|
||||
: appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"needsUpdate", true,
|
||||
"versionName", v.getVersionName(),
|
||||
@ -53,8 +68,8 @@ public class AppVersionController {
|
||||
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
|
||||
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
|
||||
"forceUpdate", v.isForceUpdate(),
|
||||
"appStoreUrl", v.getAppStoreUrl() != null ? v.getAppStoreUrl() : "",
|
||||
"marketUrl", v.getMarketUrl() != null ? v.getMarketUrl() : ""
|
||||
"appStoreUrl", appStoreJumpUrl,
|
||||
"marketUrl", harmonyJumpUrl
|
||||
)));
|
||||
}
|
||||
|
||||
@ -74,6 +89,7 @@ public class AppVersionController {
|
||||
@RequestParam(defaultValue = "false") boolean autoPublishAfterReview,
|
||||
@RequestParam(defaultValue = "false") boolean publishImmediately,
|
||||
@RequestParam(required = false) String packageName,
|
||||
@RequestParam(required = false) String expectedPackageName,
|
||||
@RequestParam(required = false) String appStoreUrl,
|
||||
@RequestParam(required = false) String marketUrl) throws Exception {
|
||||
|
||||
@ -92,29 +108,29 @@ public class AppVersionController {
|
||||
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
|
||||
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
|
||||
}
|
||||
if (platform != AppVersionEntity.Platform.HARMONY && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
|
||||
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID and IOS releases");
|
||||
if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) {
|
||||
throw new IllegalArgumentException("packageName does not match current app packageName");
|
||||
}
|
||||
if (platform == AppVersionEntity.Platform.HARMONY && !hasText(marketUrl)) {
|
||||
throw new IllegalArgumentException("marketUrl is required for HARMONY releases");
|
||||
if (platform == AppVersionEntity.Platform.ANDROID && !hasText(apkUrl) && (apkFile == null || apkFile.isEmpty())) {
|
||||
throw new IllegalArgumentException("apkUrl or apkFile is required for ANDROID releases");
|
||||
}
|
||||
if (platform == AppVersionEntity.Platform.HARMONY && !hasText(resolvedPackageName)) {
|
||||
throw new IllegalArgumentException("packageName is required for HARMONY releases");
|
||||
}
|
||||
|
||||
AppVersionEntity entity = new AppVersionEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
entity.setPlatform(platform);
|
||||
entity.setVersionName(resolvedVersionName);
|
||||
entity.setVersionCode(resolvedVersionCode);
|
||||
entity.setDownloadUrl(platform == AppVersionEntity.Platform.HARMONY
|
||||
? null
|
||||
: (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile)));
|
||||
entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID
|
||||
? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))
|
||||
: null);
|
||||
entity.setChangeLog(changeLog);
|
||||
entity.setForceUpdate(forceUpdate);
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setStoreSubmitMode("MANUAL");
|
||||
entity.setStoreSubmitScheduledAt(null);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||
}
|
||||
@ -122,8 +138,16 @@ public class AppVersionController {
|
||||
entity.setStoreSubmitTargets(storeSubmitTargets);
|
||||
entity.setAutoPublishAfterReview(autoPublishAfterReview);
|
||||
entity.setPackageName(resolvedPackageName);
|
||||
entity.setAppStoreUrl(appStoreUrl);
|
||||
entity.setMarketUrl(marketUrl);
|
||||
entity.setAppStoreUrl(hasText(appStoreUrl)
|
||||
? appStoreUrl
|
||||
: (platform == AppVersionEntity.Platform.IOS
|
||||
? appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE)
|
||||
: null));
|
||||
entity.setMarketUrl(hasText(marketUrl)
|
||||
? marketUrl
|
||||
: (platform == AppVersionEntity.Platform.HARMONY
|
||||
? appStoreService.getStoreJumpUrl(appId, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP)
|
||||
: null));
|
||||
if (publishImmediately) {
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||
entity.setGrayEnabled(false);
|
||||
@ -143,11 +167,29 @@ public class AppVersionController {
|
||||
}
|
||||
|
||||
@PostMapping("/app/{id}/publish")
|
||||
public ResponseEntity<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();
|
||||
boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately"));
|
||||
String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null
|
||||
? body.get("scheduledPublishAt").toString() : null;
|
||||
boolean forceUpdate = body != null && body.get("forceUpdate") != null
|
||||
? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate();
|
||||
entity.setForceUpdate(forceUpdate);
|
||||
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||
entity.setScheduledPublishAt(null);
|
||||
} else {
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||
}
|
||||
}
|
||||
entity.setGrayEnabled(false);
|
||||
entity.setGrayPercent(0);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
||||
}
|
||||
|
||||
@ -161,10 +203,30 @@ public class AppVersionController {
|
||||
@PostMapping("/app/{id}/gray")
|
||||
public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
|
||||
entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled")));
|
||||
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
||||
entity.setGrayEnabled(enabled);
|
||||
if (!enabled) {
|
||||
entity.setGrayPercent(0);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
} else if ("MEMBERS".equals(grayMode)) {
|
||||
List<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);
|
||||
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
|
||||
}
|
||||
@ -179,4 +241,39 @@ public class AppVersionController {
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private List<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.model.RnBundleInspectResult;
|
||||
import com.xuqm.update.repository.RnBundleRepository;
|
||||
import com.xuqm.update.service.PublishConfigService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -22,13 +23,17 @@ public class RnBundleController {
|
||||
|
||||
private final RnBundleRepository bundleRepository;
|
||||
private final UpdateAssetService updateAssetService;
|
||||
private final PublishConfigService publishConfigService;
|
||||
|
||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||
private String baseUrl;
|
||||
|
||||
public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) {
|
||||
public RnBundleController(RnBundleRepository bundleRepository,
|
||||
UpdateAssetService updateAssetService,
|
||||
PublishConfigService publishConfigService) {
|
||||
this.bundleRepository = bundleRepository;
|
||||
this.updateAssetService = updateAssetService;
|
||||
this.publishConfigService = publishConfigService;
|
||||
}
|
||||
|
||||
@GetMapping("/update/check")
|
||||
@ -52,6 +57,7 @@ public class RnBundleController {
|
||||
boolean needsUpdate = !b.getVersion().equals(currentVersion);
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"needsUpdate", needsUpdate,
|
||||
"bundleVersion", parseBundleVersion(b.getVersion()),
|
||||
"latestVersion", b.getVersion(),
|
||||
"downloadUrl", resolvePublicBaseUrl() + "/api/v1/rn/files/" + appId + "/" + platform.toLowerCase() + "/" + moduleId,
|
||||
"md5", b.getMd5(),
|
||||
@ -97,6 +103,10 @@ public class RnBundleController {
|
||||
entity.setPackageName(resolvedPackageName);
|
||||
entity.setNote(note);
|
||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||
entity.setPublishMode("MANUAL");
|
||||
entity.setScheduledPublishAt(null);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||
}
|
||||
@ -124,11 +134,28 @@ public class RnBundleController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/publish")
|
||||
public ResponseEntity<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();
|
||||
boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately"));
|
||||
String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null
|
||||
? body.get("scheduledPublishAt").toString() : null;
|
||||
entity.setPublishMode(publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())
|
||||
? "NOW" : "SCHEDULED");
|
||||
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
|
||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
|
||||
entity.setScheduledPublishAt(null);
|
||||
} else {
|
||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||
entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt));
|
||||
}
|
||||
}
|
||||
entity.setGrayEnabled(false);
|
||||
entity.setGrayPercent(0);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||
}
|
||||
|
||||
@ -142,10 +169,30 @@ public class RnBundleController {
|
||||
@PostMapping("/{id}/gray")
|
||||
public ResponseEntity<ApiResponse<RnBundleEntity>> gray(
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
|
||||
entity.setGrayEnabled(Boolean.TRUE.equals(body.get("enabled")));
|
||||
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
||||
entity.setGrayEnabled(enabled);
|
||||
if (!enabled) {
|
||||
entity.setGrayPercent(0);
|
||||
entity.setGrayMode("PERCENT");
|
||||
entity.setGrayMemberIds(null);
|
||||
} else if ("MEMBERS".equals(grayMode)) {
|
||||
List<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);
|
||||
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
|
||||
}
|
||||
@ -173,4 +220,50 @@ public class RnBundleController {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int parseBundleVersion(String version) {
|
||||
if (!hasText(version)) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(version.trim());
|
||||
} catch (Exception ignored) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(List<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<>();
|
||||
for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) {
|
||||
MultipartFile file = multipartRequest.getFile(item.fileKey());
|
||||
if (item.platform() == AppVersionEntity.Platform.ANDROID && file == null) {
|
||||
throw new IllegalArgumentException("Android app version requires an uploaded package file");
|
||||
}
|
||||
AppVersionEntity entity = new AppVersionEntity();
|
||||
entity.setId(UUID.randomUUID().toString());
|
||||
entity.setAppId(appId);
|
||||
@ -72,7 +75,9 @@ public class UnifiedReleaseController {
|
||||
entity.setMarketUrl(item.marketUrl());
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
entity.setDownloadUrl(file != null ? updateAssetService.storeAppPackage(file) : null);
|
||||
entity.setDownloadUrl(item.platform() == AppVersionEntity.Platform.ANDROID && file != null
|
||||
? updateAssetService.storeAppPackage(file)
|
||||
: null);
|
||||
if (item.publishImmediately()) {
|
||||
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||
entity.setGrayEnabled(false);
|
||||
|
||||
@ -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.
|
||||
* Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY
|
||||
* iOS: APP_STORE
|
||||
* Harmony: HARMONY_APP
|
||||
*/
|
||||
public enum StoreType {
|
||||
HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE;
|
||||
HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE, HARMONY_APP, REVIEW_WEBHOOK;
|
||||
|
||||
public boolean isAndroid() {
|
||||
return this != APP_STORE;
|
||||
return this != APP_STORE && this != HARMONY_APP && this != REVIEW_WEBHOOK;
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,13 +35,16 @@ public class AppStoreConfigEntity {
|
||||
|
||||
/**
|
||||
* Store-specific credentials stored as a flat JSON object.
|
||||
* Every store config also carries its own marketUrl / jump page.
|
||||
*
|
||||
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
|
||||
* MI: {"username":"...","privateKey":"..."}
|
||||
* OPPO: {"clientId":"...","clientSecret":"..."}
|
||||
* VIVO: {"accessKey":"...","accessSecret":"..."}
|
||||
* GOOGLE_PLAY: {"serviceAccountJson":"..."}
|
||||
* APP_STORE: {"teamId":"...","keyId":"...","privateKey":"...","bundleId":"..."}
|
||||
* APP_STORE: {"marketUrl":"..."}
|
||||
* HARMONY_APP: {"marketUrl":"..."}
|
||||
* REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."}
|
||||
*/
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String configJson;
|
||||
|
||||
@ -46,6 +46,11 @@ public class AppVersionEntity {
|
||||
@Column(nullable = false, length = 16)
|
||||
private PublishStatus publishStatus;
|
||||
|
||||
@Column(length = 16)
|
||||
private String storeSubmitMode = "MANUAL";
|
||||
|
||||
private LocalDateTime storeSubmitScheduledAt;
|
||||
|
||||
@Column(length = 256)
|
||||
private String appStoreUrl;
|
||||
|
||||
@ -83,6 +88,14 @@ public class AppVersionEntity {
|
||||
@Column(length = 512)
|
||||
private String webhookUrl;
|
||||
|
||||
/** Gray release mode: PERCENT or MEMBERS. */
|
||||
@Column(length = 16)
|
||||
private String grayMode = "PERCENT";
|
||||
|
||||
/** JSON array of gray member userIds. */
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String grayMemberIds;
|
||||
|
||||
/** App package name / bundle identifier, e.g. com.example.myapp */
|
||||
@Column(length = 256)
|
||||
private String packageName;
|
||||
@ -117,6 +130,12 @@ public class AppVersionEntity {
|
||||
public PublishStatus getPublishStatus() { return publishStatus; }
|
||||
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; }
|
||||
|
||||
public String getStoreSubmitMode() { return storeSubmitMode; }
|
||||
public void setStoreSubmitMode(String storeSubmitMode) { this.storeSubmitMode = storeSubmitMode; }
|
||||
|
||||
public LocalDateTime getStoreSubmitScheduledAt() { return storeSubmitScheduledAt; }
|
||||
public void setStoreSubmitScheduledAt(LocalDateTime storeSubmitScheduledAt) { this.storeSubmitScheduledAt = storeSubmitScheduledAt; }
|
||||
|
||||
public String getAppStoreUrl() { return appStoreUrl; }
|
||||
public void setAppStoreUrl(String appStoreUrl) { this.appStoreUrl = appStoreUrl; }
|
||||
|
||||
@ -149,4 +168,10 @@ public class AppVersionEntity {
|
||||
|
||||
public String getWebhookUrl() { return webhookUrl; }
|
||||
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
|
||||
|
||||
public String getGrayMode() { return grayMode; }
|
||||
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
|
||||
|
||||
public String getGrayMemberIds() { return grayMemberIds; }
|
||||
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
|
||||
}
|
||||
|
||||
@ -50,12 +50,23 @@ public class RnBundleEntity {
|
||||
@Column(nullable = false, length = 16)
|
||||
private PublishStatus publishStatus;
|
||||
|
||||
@Column(length = 16)
|
||||
private String publishMode = "MANUAL";
|
||||
|
||||
private LocalDateTime scheduledPublishAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean grayEnabled = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int grayPercent = 0;
|
||||
|
||||
@Column(length = 16)
|
||||
private String grayMode = "PERCENT";
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String grayMemberIds;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ -92,12 +103,24 @@ public class RnBundleEntity {
|
||||
public PublishStatus getPublishStatus() { return publishStatus; }
|
||||
public void setPublishStatus(PublishStatus publishStatus) { this.publishStatus = publishStatus; }
|
||||
|
||||
public String getPublishMode() { return publishMode; }
|
||||
public void setPublishMode(String publishMode) { this.publishMode = publishMode; }
|
||||
|
||||
public LocalDateTime getScheduledPublishAt() { return scheduledPublishAt; }
|
||||
public void setScheduledPublishAt(LocalDateTime scheduledPublishAt) { this.scheduledPublishAt = scheduledPublishAt; }
|
||||
|
||||
public boolean isGrayEnabled() { return grayEnabled; }
|
||||
public void setGrayEnabled(boolean grayEnabled) { this.grayEnabled = grayEnabled; }
|
||||
|
||||
public int getGrayPercent() { return grayPercent; }
|
||||
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
|
||||
|
||||
public String getGrayMode() { return grayMode; }
|
||||
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
|
||||
|
||||
public String getGrayMemberIds() { return grayMemberIds; }
|
||||
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore(
|
||||
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.Optional;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String> {
|
||||
List<RnBundleEntity> findByAppIdAndModuleIdAndPlatformOrderByCreatedAtDesc(
|
||||
@ -13,4 +14,7 @@ public interface RnBundleRepository extends JpaRepository<RnBundleEntity, String
|
||||
List<RnBundleEntity> findByAppIdOrderByCreatedAtDesc(String appId);
|
||||
Optional<RnBundleEntity> findTopByAppIdAndModuleIdAndPlatformAndPublishStatusOrderByCreatedAtDesc(
|
||||
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.xuqm.update.entity.AppStoreConfigEntity;
|
||||
import com.xuqm.update.entity.AppVersionEntity;
|
||||
import com.xuqm.update.entity.RnBundleEntity;
|
||||
import com.xuqm.update.repository.AppStoreConfigRepository;
|
||||
import com.xuqm.update.repository.AppVersionRepository;
|
||||
import com.xuqm.update.repository.RnBundleRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
@ -27,10 +29,14 @@ public class AppStoreService {
|
||||
|
||||
private final AppStoreConfigRepository configRepo;
|
||||
private final AppVersionRepository versionRepo;
|
||||
private final RnBundleRepository rnBundleRepository;
|
||||
|
||||
public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo) {
|
||||
public AppStoreService(AppStoreConfigRepository configRepo,
|
||||
AppVersionRepository versionRepo,
|
||||
RnBundleRepository rnBundleRepository) {
|
||||
this.configRepo = configRepo;
|
||||
this.versionRepo = versionRepo;
|
||||
this.rnBundleRepository = rnBundleRepository;
|
||||
}
|
||||
|
||||
// ── Store config CRUD ────────────────────────────────────────────────────
|
||||
@ -70,7 +76,11 @@ public class AppStoreService {
|
||||
* machine (it has the APK/IPA file). This endpoint records the intent and provides the
|
||||
* credentials the script needs.
|
||||
*/
|
||||
public AppVersionEntity markSubmitted(String versionId, List<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();
|
||||
|
||||
Map<String, String> reviewMap = new LinkedHashMap<>();
|
||||
@ -79,9 +89,18 @@ public class AppStoreService {
|
||||
}
|
||||
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
|
||||
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
||||
v.setStoreSubmitMode(submitMode == null || submitMode.isBlank() ? "MANUAL" : submitMode.trim().toUpperCase(Locale.ROOT));
|
||||
v.setStoreSubmitScheduledAt(scheduledAt);
|
||||
if (autoPublishAfterReview != null) {
|
||||
v.setAutoPublishAfterReview(autoPublishAfterReview);
|
||||
}
|
||||
return versionRepo.save(v);
|
||||
}
|
||||
|
||||
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
|
||||
return markSubmitted(versionId, storeTypes, "MANUAL", null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch enabled store credentials for use by the release script.
|
||||
* Returns a map of storeType -> configJson (as parsed map, not raw string).
|
||||
@ -90,6 +109,9 @@ public class AppStoreService {
|
||||
List<AppStoreConfigEntity> configs = configRepo.findByAppIdAndEnabled(appId, true);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (AppStoreConfigEntity cfg : configs) {
|
||||
if (cfg.getStoreType() == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> parsed = cfg.getConfigJson() != null
|
||||
? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {})
|
||||
: Map.of();
|
||||
@ -98,6 +120,26 @@ public class AppStoreService {
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@ -132,15 +174,34 @@ public class AppStoreService {
|
||||
AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now());
|
||||
for (AppVersionEntity v : due) {
|
||||
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||
v.setScheduledPublishAt(null);
|
||||
versionRepo.save(v);
|
||||
log.info("Scheduled publish executed for version {}", v.getId());
|
||||
}
|
||||
|
||||
List<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 ─────────────────────────────────────────────────────
|
||||
|
||||
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) {
|
||||
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;
|
||||
|
||||
try {
|
||||
@ -154,9 +215,11 @@ public class AppStoreService {
|
||||
"publishStatus", v.getPublishStatus().name(),
|
||||
"timestamp", System.currentTimeMillis()
|
||||
));
|
||||
String secret = resolveWebhookSecret(v.getAppId());
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Xuqm-Webhook-Secret", secret == null ? "" : secret)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
http.sendAsync(request, HttpResponse.BodyHandlers.discarding())
|
||||
@ -179,4 +242,25 @@ public class AppStoreService {
|
||||
return targets.stream().allMatch(t ->
|
||||
AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t)));
|
||||
}
|
||||
|
||||
private String resolveWebhookSecret(String appId) {
|
||||
try {
|
||||
return getReviewWebhookConfig(appId).getOrDefault("secret", "");
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String extractJumpUrl(String configJson) {
|
||||
if (configJson == null || configJson.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Map<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;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.xuqm.update.entity.AppStoreConfigEntity;
|
||||
import com.xuqm.update.entity.AppVersionEntity;
|
||||
@ -12,14 +13,24 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
@ -68,6 +79,12 @@ public class StoreSubmissionService {
|
||||
AppVersionEntity v = versionRepo.findById(versionId).orElse(null);
|
||||
if (v == null) { log.error("Version not found: {}", versionId); return; }
|
||||
|
||||
if ("SCHEDULED".equalsIgnoreCase(v.getStoreSubmitMode())) {
|
||||
v.setStoreSubmitMode("AUTO_REVIEW");
|
||||
v.setStoreSubmitScheduledAt(null);
|
||||
versionRepo.save(v);
|
||||
}
|
||||
|
||||
List<String> targets = parseTargets(v.getStoreSubmitTargets());
|
||||
if (targets.isEmpty()) { log.warn("No store targets for version {}", versionId); return; }
|
||||
|
||||
@ -99,6 +116,15 @@ public class StoreSubmissionService {
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60_000)
|
||||
public void processScheduledStoreSubmissions() {
|
||||
List<AppVersionEntity> due = versionRepo.findByStoreSubmitModeAndStoreSubmitScheduledAtBefore(
|
||||
"SCHEDULED", java.time.LocalDateTime.now());
|
||||
for (AppVersionEntity v : due) {
|
||||
executeSubmitAsync(v.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void submitToStore(String storeType, AppVersionEntity v, File file,
|
||||
@ -111,6 +137,7 @@ public class StoreSubmissionService {
|
||||
case "VIVO" -> submitToVivo(v, file, creds);
|
||||
case "APP_STORE" -> submitToAppStore(v, file, creds);
|
||||
case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds);
|
||||
case "HARMONY_APP" -> log.warn("Harmony app store submission is not implemented yet - keep this channel for market link and manual review tracking");
|
||||
default -> throw new IllegalArgumentException("Unknown store: " + storeType);
|
||||
}
|
||||
}
|
||||
@ -262,23 +289,54 @@ public class StoreSubmissionService {
|
||||
// API: https://dev.mi.com/distribute/doc/details?pId=1134
|
||||
|
||||
private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||
// Xiaomi submission is intentionally non-blocking for now.
|
||||
// Keep the release flow moving even when one store channel still needs manual completion.
|
||||
log.warn("MI store submission not yet implemented - leaving review state as UNDER_REVIEW for manual completion");
|
||||
try {
|
||||
String account = resolveMiAccount(creds);
|
||||
String publicKey = require(creds, "publicKey", "MI");
|
||||
String privateKey = require(creds, "privateKey", "MI");
|
||||
String packageName = requirePackageName(v);
|
||||
JsonNode appInfo = miGetAppInfo(account, packageName, publicKey, privateKey);
|
||||
String appName = appInfo.path("packageInfo").path("appName").asText(packageName);
|
||||
miUploadApk(file, account, appName, packageName, v.getChangeLog(), publicKey, privateKey);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("MI store submission failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── OPPO Software Store ───────────────────────────────────────────────────
|
||||
// API: https://open.oppomobile.com/new/developmentDoc/info?id=11119
|
||||
|
||||
private void submitToOppo(AppVersionEntity v, File file, Map<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 ────────────────────────────────────────────────────────
|
||||
// API: https://dev.vivo.com.cn/documentCenter/doc/326
|
||||
|
||||
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 ───────────────────────────────────────────────
|
||||
@ -296,6 +354,351 @@ public class StoreSubmissionService {
|
||||
|
||||
// ── 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) {
|
||||
if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null");
|
||||
String path = URI.create(downloadUrl).getPath();
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.xuqm.update.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.xuqm.update.model.AppPackageInspectResult;
|
||||
import com.xuqm.update.model.RnBundleInspectResult;
|
||||
import net.dongliu.apk.parser.ApkFile;
|
||||
@ -40,6 +42,7 @@ import org.w3c.dom.NodeList;
|
||||
public class UpdateAssetService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UpdateAssetService.class);
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Value("${update.upload-dir:/tmp/xuqm-update}")
|
||||
private String uploadDir;
|
||||
@ -47,6 +50,10 @@ public class UpdateAssetService {
|
||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||
private String baseUrl;
|
||||
|
||||
public UpdateAssetService(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public String storeAppPackage(MultipartFile apkFile) throws IOException {
|
||||
if (apkFile == null || apkFile.isEmpty()) {
|
||||
return null;
|
||||
@ -109,6 +116,20 @@ public class UpdateAssetService {
|
||||
if (bundle == null || bundle.isEmpty()) {
|
||||
return new RnBundleInspectResult(null, null, null, null, null, fileName, false);
|
||||
}
|
||||
if (isZipBundle(fileName)) {
|
||||
Path temp = Files.createTempFile("xuqm-rn-bundle-inspect-", suffixFor(fileName));
|
||||
try {
|
||||
try (InputStream in = bundle.getInputStream()) {
|
||||
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
RnBundleInspectResult zipped = inspectRnBundleZip(temp, fileName);
|
||||
if (zipped.detected()) {
|
||||
return zipped;
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temp);
|
||||
}
|
||||
}
|
||||
return inspectRnBundleName(fileName);
|
||||
}
|
||||
|
||||
@ -116,7 +137,7 @@ public class UpdateAssetService {
|
||||
if (bundle == null || bundle.isEmpty()) {
|
||||
throw new IllegalArgumentException("bundle file is required");
|
||||
}
|
||||
String filename = moduleId + "." + platform.toLowerCase() + ".bundle";
|
||||
String filename = resolveBundleFilename(moduleId, platform, bundle.getOriginalFilename());
|
||||
Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId);
|
||||
Files.createDirectories(dir);
|
||||
Path dest = dir.resolve(filename);
|
||||
@ -209,6 +230,34 @@ public class UpdateAssetService {
|
||||
false);
|
||||
}
|
||||
|
||||
private RnBundleInspectResult inspectRnBundleZip(Path file, String fileName) throws Exception {
|
||||
try (ZipFile zipFile = new ZipFile(file.toFile())) {
|
||||
ZipEntry entry = zipFile.getEntry("rn-manifest.json");
|
||||
if (entry == null) {
|
||||
entry = zipFile.getEntry("manifest.json");
|
||||
}
|
||||
if (entry != null) {
|
||||
try (InputStream in = zipFile.getInputStream(entry)) {
|
||||
JsonNode node = objectMapper.readTree(in);
|
||||
String moduleId = text(node, "moduleId");
|
||||
String platform = text(node, "platform");
|
||||
String version = firstText(node, "bundleVersion", "version");
|
||||
String minCommonVersion = text(node, "minCommonVersion");
|
||||
String packageName = text(node, "packageName");
|
||||
return new RnBundleInspectResult(
|
||||
moduleId,
|
||||
platformFromToken(platform),
|
||||
version,
|
||||
minCommonVersion,
|
||||
packageName,
|
||||
fileName,
|
||||
hasText(moduleId) && hasText(version) && hasText(platform));
|
||||
}
|
||||
}
|
||||
}
|
||||
return inspectRnBundleName(fileName);
|
||||
}
|
||||
|
||||
private Map<String, String> parsePlistXml(String xml) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(false);
|
||||
@ -270,6 +319,24 @@ public class UpdateAssetService {
|
||||
return idx > 0 ? fileName.substring(idx) : ".tmp";
|
||||
}
|
||||
|
||||
private boolean isZipBundle(String fileName) {
|
||||
String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT);
|
||||
return lower.endsWith(".zip") || lower.endsWith(".bundle.zip") || lower.endsWith(".tar.gz");
|
||||
}
|
||||
|
||||
private String resolveBundleFilename(String moduleId, String platform, String originalFilename) {
|
||||
String safeOriginal = Optional.ofNullable(originalFilename).orElse("").trim();
|
||||
String ext = "";
|
||||
int idx = safeOriginal.lastIndexOf('.');
|
||||
if (idx > 0 && idx < safeOriginal.length() - 1) {
|
||||
ext = safeOriginal.substring(idx);
|
||||
}
|
||||
if (!hasText(ext)) {
|
||||
ext = ".zip";
|
||||
}
|
||||
return moduleId + "." + platform.toLowerCase(Locale.ROOT) + ext;
|
||||
}
|
||||
|
||||
private RemotePackage downloadRemotePackage(String packageUrl, boolean tempFile) throws IOException {
|
||||
HttpURLConnection connection = (HttpURLConnection) new URL(packageUrl).openConnection();
|
||||
connection.setConnectTimeout(15_000);
|
||||
@ -371,6 +438,19 @@ public class UpdateAssetService {
|
||||
return value == null || value.isBlank() ? null : value.trim();
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field) {
|
||||
if (node == null || !node.hasNonNull(field)) {
|
||||
return null;
|
||||
}
|
||||
String value = node.get(field).asText();
|
||||
return blankToNull(value);
|
||||
}
|
||||
|
||||
private String firstText(JsonNode node, String primaryField, String fallbackField) {
|
||||
String primary = text(node, primaryField);
|
||||
return hasText(primary) ? primary : text(node, fallbackField);
|
||||
}
|
||||
|
||||
private AppPackageInspectResult fallbackInspectResult(String source) {
|
||||
String fileName = source;
|
||||
try {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户