docs(sdk): 添加 React Native SDK 文档和 Android/HarmonyOS 发版脚本

- 新增 XuqmGroup React Native SDK 使用文档,包含安装、初始化、HTTP客户端、IM模块、推送模块、版本管理等功能说明
- 添加 Android Gradle 发版任务脚本,支持构建发布 APK 并上传到更新服务
- 添加 HarmonyOS hvigorw 发版任务脚本,支持 HAP 包构建和上传功能
- 实现多平台版本检查、自动重连、灰度发布等发版流程自动化
- 集成商店提交、定时发布、Webhook 回调等发布后处理功能
这个提交包含在:
XuqmGroup 2026-04-29 17:35:52 +08:00
父节点 d13c6c9bc5
当前提交 f5a1eb4470
共有 14 个文件被更改,包括 558 次插入63 次删除

查看文件

@ -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`
**上传 APKmultipart/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,12 +71,8 @@ public class FeatureServiceController {
@RequestBody FeatureServiceConfigRequest req,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
appId,
platform,
serviceType,
serviceType == FeatureServiceEntity.ServiceType.IM
? featureServiceManager.buildImConfig(
String config = switch (serviceType) {
case IM -> featureServiceManager.buildImConfig(
appId,
platform,
req == null ? null : req.allowStrangerMessage(),
@ -78,10 +83,26 @@ public class FeatureServiceController {
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()))
)));
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, 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();
}
public record StoredRnBundle(String bundlePath, String md5) {}
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) {}
}