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