feat(update): 添加应用更新检查功能支持用户ID参数

- 在UpdateApi接口中新增可选的userId查询参数
- 新增UpdateSDK对象用于统一管理应用更新逻辑
- 实现应用版本检查、下载安装和APK文件处理功能
- 添加下载URL规范化处理逻辑
- 在Flutter SDK中新增update模块实现跨平台更新功能
- 在iOS SDK中新增UpdateSDK类提供应用更新检查接口
- 支持Android和iOS平台的应用商店跳转功能
- 添加React Native SDK的更新检查和插件注册功能
- 实现RN Bundle的检查、下载和缓存机制
这个提交包含在:
XuqmGroup 2026-05-08 12:00:33 +08:00
父节点 3e55e9d9b6
当前提交 0385b2010a
共有 10 个文件被更改,包括 150 次插入17 次删除

查看文件

@ -176,6 +176,7 @@
- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧直接传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 - `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧直接传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。
- 应用商店配置页分成两个 tab`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。 - 应用商店配置页分成两个 tab`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret` - 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`
- 发布配置里新增 `allowAnonymousUpdateCheck` 开关,默认关闭。关闭时更新检查仍要求登录,且灰度发布可正常使用;开启后允许未登录设备检查更新,但所有灰度相关能力都会被禁用,服务端和租户平台都不会再允许操作灰度配置。
- `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 - `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。
- 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload``POST /api/v1/updates/app/inspect` - 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload``POST /api/v1/updates/app/inspect`
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。 - 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。
@ -203,6 +204,7 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传
- `xuqm.publishImmediately` - `xuqm.publishImmediately`
- `xuqm.scheduledPublishAt` - `xuqm.scheduledPublishAt`
- `xuqm.autoPublishAfterReview` - `xuqm.autoPublishAfterReview`
- `xuqm.allowAnonymousUpdateCheck`
- `xuqm.webhookUrl` - `xuqm.webhookUrl`
- `xuqm.forceUpdate` - `xuqm.forceUpdate`
- `xuqm.grayEnabled` - `xuqm.grayEnabled`
@ -215,9 +217,10 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传
2. 如果服务未开通或配置缺失,则进入 dry-run 或提示补齐配置。 2. 如果服务未开通或配置缺失,则进入 dry-run 或提示补齐配置。
3. 脚本读取服务器最新版本,和本地版本对比。 3. 脚本读取服务器最新版本,和本地版本对比。
4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。 4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否允许免登录检查更新、是否使用 webhook。
6. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。 6. 如果启用了免登录检查更新,灰度配置与灰度操作都不再可用。
7. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 7. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。
8. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。

查看文件

@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.OperationLogEntity; import com.xuqm.tenant.entity.OperationLogEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.dto.ServiceRequestView;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.entity.RiskConfigEntity; import com.xuqm.tenant.entity.RiskConfigEntity;
@ -97,7 +98,7 @@ public class OpsController {
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) { @RequestParam(defaultValue = "20") int size) {
Page<ServiceActivationRequestEntity> result = opsService.listServiceRequests(status, page, size); Page<ServiceRequestView> result = opsService.listServiceRequests(status, page, size);
return ResponseEntity.ok(ApiResponse.success(Map.of( return ResponseEntity.ok(ApiResponse.success(Map.of(
"content", result.getContent(), "content", result.getContent(),
"total", result.getTotalElements(), "total", result.getTotalElements(),

查看文件

@ -0,0 +1,14 @@
package com.xuqm.tenant.dto;
import java.time.LocalDateTime;
public record AppSummary(
String id,
String appKey,
String name,
String packageName,
String iosBundleId,
String harmonyBundleName,
String tenantId,
LocalDateTime createdAt
) {}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.tenant.dto;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import java.time.LocalDateTime;
public record ServiceRequestView(
String id,
String appKey,
FeatureServiceEntity.Platform platform,
FeatureServiceEntity.ServiceType serviceType,
ServiceActivationRequestEntity.Status status,
String applyReason,
String reviewNote,
LocalDateTime createdAt,
LocalDateTime reviewedAt,
TenantSummary tenant,
AppSummary app
) {}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.tenant.dto;
import com.xuqm.tenant.entity.TenantEntity;
public record TenantSummary(
String id,
String username,
String nickname,
String email,
String phone,
TenantEntity.Type type,
TenantEntity.Status status
) {}

查看文件

@ -1,6 +1,9 @@
package com.xuqm.tenant.service; package com.xuqm.tenant.service;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import com.xuqm.tenant.dto.AppSummary;
import com.xuqm.tenant.dto.ServiceRequestView;
import com.xuqm.tenant.dto.TenantSummary;
import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.OpsAdminEntity; import com.xuqm.tenant.entity.OpsAdminEntity;
@ -145,14 +148,52 @@ public class OpsService {
); );
} }
public Page<ServiceActivationRequestEntity> listServiceRequests(String statusStr, int page, int size) { public Page<ServiceRequestView> listServiceRequests(String statusStr, int page, int size) {
var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); var pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<ServiceActivationRequestEntity> requests;
if (statusStr != null && !statusStr.isEmpty()) { if (statusStr != null && !statusStr.isEmpty()) {
ServiceActivationRequestEntity.Status status = ServiceActivationRequestEntity.Status status =
ServiceActivationRequestEntity.Status.valueOf(statusStr.toUpperCase()); ServiceActivationRequestEntity.Status.valueOf(statusStr.toUpperCase());
return requestRepository.findByStatusOrderByCreatedAtDesc(status, pageable); requests = requestRepository.findByStatusOrderByCreatedAtDesc(status, pageable);
} else {
requests = requestRepository.findAllByOrderByCreatedAtDesc(pageable);
} }
return requestRepository.findAllByOrderByCreatedAtDesc(pageable); return requests.map(this::toView);
}
private ServiceRequestView toView(ServiceActivationRequestEntity request) {
AppEntity app = appRepository.findByAppKey(request.getAppKey()).orElse(null);
TenantEntity tenant = app != null ? tenantRepository.findById(app.getTenantId()).orElse(null) : null;
return new ServiceRequestView(
request.getId(),
request.getAppKey(),
request.getPlatform(),
request.getServiceType(),
request.getStatus(),
request.getApplyReason(),
request.getReviewNote(),
request.getCreatedAt(),
request.getReviewedAt(),
tenant == null ? null : new TenantSummary(
tenant.getId(),
tenant.getUsername(),
tenant.getNickname(),
tenant.getEmail(),
tenant.getPhone(),
tenant.getType(),
tenant.getStatus()
),
app == null ? null : new AppSummary(
app.getId(),
app.getAppKey(),
app.getName(),
app.getPackageName(),
app.getIosBundleId(),
app.getHarmonyBundleName(),
app.getTenantId(),
app.getCreatedAt()
)
);
} }
public Page<AppEntity> listApps(String keyword, int page, int size) { public Page<AppEntity> listApps(String keyword, int page, int size) {

查看文件

@ -52,6 +52,11 @@ public class AppVersionController {
@RequestParam int currentVersionCode, @RequestParam int currentVersionCode,
@RequestParam(required = false) String userId) { @RequestParam(required = false) String userId) {
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
Optional<AppVersionEntity> latest = versionRepository Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
@ -66,7 +71,7 @@ public class AppVersionController {
AppVersionEntity v = latest.get(); AppVersionEntity v = latest.get();
// Gray release filtering // Gray release filtering
if (v.isGrayEnabled() && userId != null && !userId.isBlank()) { if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false; boolean inGray = false;
if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) { if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) {
inGray = v.getGrayMemberIds().contains(userId); inGray = v.getGrayMemberIds().contains(userId);
@ -285,6 +290,9 @@ public class AppVersionController {
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, Object> body) throws Exception { @RequestBody Map<String, Object> body) throws Exception {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppId())) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = 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(); String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
entity.setGrayEnabled(enabled); entity.setGrayEnabled(enabled);

查看文件

@ -37,6 +37,9 @@ public class PublishConfigController {
@RequestParam String appKey, @RequestParam String appKey,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String groupName) { @RequestParam(required = false) String groupName) {
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
}
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
publishConfigService.listGrayMembers(appKey, keyword, groupName))); publishConfigService.listGrayMembers(appKey, keyword, groupName)));
} }
@ -44,6 +47,9 @@ public class PublishConfigController {
@PostMapping("/gray/members/sync") @PostMapping("/gray/members/sync")
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> syncMembers( public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> syncMembers(
@RequestParam String appKey) { @RequestParam String appKey) {
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
}
return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appKey))); return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appKey)));
} }
@ -51,6 +57,9 @@ public class PublishConfigController {
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> importMembers( public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> importMembers(
@RequestParam String appKey, @RequestParam String appKey,
@RequestBody String payload) { @RequestBody String payload) {
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
}
return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appKey, payload))); return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appKey, payload)));
} }

查看文件

@ -46,7 +46,13 @@ public class RnBundleController {
@RequestParam String moduleId, @RequestParam String moduleId,
@RequestParam String platform, @RequestParam String platform,
@RequestParam String currentVersion, @RequestParam String currentVersion,
@RequestParam(required = false) String packageName) { @RequestParam(required = false) String packageName,
@RequestParam(required = false) String userId) {
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase()); RnBundleEntity.Platform p = RnBundleEntity.Platform.valueOf(platform.toUpperCase());
Optional<RnBundleEntity> latest = bundleRepository Optional<RnBundleEntity> latest = bundleRepository
@ -59,6 +65,18 @@ public class RnBundleController {
RnBundleEntity b = latest.get(); RnBundleEntity b = latest.get();
boolean needsUpdate = !b.getVersion().equals(currentVersion); boolean needsUpdate = !b.getVersion().equals(currentVersion);
if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false;
if ("MEMBERS".equals(b.getGrayMode()) && b.getGrayMemberIds() != null) {
inGray = b.getGrayMemberIds().contains(userId);
} else {
int hash = Math.abs(userId.hashCode()) % 100;
inGray = hash < b.getGrayPercent();
}
if (!inGray) {
needsUpdate = false;
}
}
return ResponseEntity.ok(ApiResponse.success(Map.of( return ResponseEntity.ok(ApiResponse.success(Map.of(
"needsUpdate", needsUpdate, "needsUpdate", needsUpdate,
"bundleVersion", parseBundleVersion(b.getVersion()), "bundleVersion", parseBundleVersion(b.getVersion()),
@ -219,6 +237,9 @@ public class RnBundleController {
@PathVariable String id, @PathVariable String id,
@RequestBody Map<String, Object> body) throws Exception { @RequestBody Map<String, Object> body) throws Exception {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); RnBundleEntity entity = bundleRepository.findById(id).orElseThrow();
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppId())) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = 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(); String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
entity.setGrayEnabled(enabled); entity.setGrayEnabled(enabled);

查看文件

@ -315,10 +315,14 @@ public class PublishConfigService {
private String defaultConfigJson() { private String defaultConfigJson() {
return """ return """
{"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"} {"allowAnonymousUpdateCheck":false,"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"}
""".trim(); """.trim();
} }
public boolean allowAnonymousUpdateCheck(String appKey) {
return getConfigNode(appKey).path("allowAnonymousUpdateCheck").asBoolean(false);
}
public record GrayMemberGroupView(String groupName, List<GrayMemberView> members) {} public record GrayMemberGroupView(String groupName, List<GrayMemberView> members) {}
public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {} public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {}
private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {} private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {}