feat(update): 添加应用更新检查功能支持用户ID参数
- 在UpdateApi接口中新增可选的userId查询参数 - 新增UpdateSDK对象用于统一管理应用更新逻辑 - 实现应用版本检查、下载安装和APK文件处理功能 - 添加下载URL规范化处理逻辑 - 在Flutter SDK中新增update模块实现跨平台更新功能 - 在iOS SDK中新增UpdateSDK类提供应用更新检查接口 - 支持Android和iOS平台的应用商店跳转功能 - 添加React Native SDK的更新检查和插件注册功能 - 实现RN Bundle的检查、下载和缓存机制
这个提交包含在:
父节点
3e55e9d9b6
当前提交
0385b2010a
@ -176,6 +176,7 @@
|
||||
- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧直接传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。
|
||||
- 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
|
||||
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`。
|
||||
- 发布配置里新增 `allowAnonymousUpdateCheck` 开关,默认关闭。关闭时更新检查仍要求登录,且灰度发布可正常使用;开启后允许未登录设备检查更新,但所有灰度相关能力都会被禁用,服务端和租户平台都不会再允许操作灰度配置。
|
||||
- `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` 的流程,不会因为解析失败直接中断。
|
||||
@ -203,6 +204,7 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传
|
||||
- `xuqm.publishImmediately`
|
||||
- `xuqm.scheduledPublishAt`
|
||||
- `xuqm.autoPublishAfterReview`
|
||||
- `xuqm.allowAnonymousUpdateCheck`
|
||||
- `xuqm.webhookUrl`
|
||||
- `xuqm.forceUpdate`
|
||||
- `xuqm.grayEnabled`
|
||||
@ -215,9 +217,10 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传
|
||||
2. 如果服务未开通或配置缺失,则进入 dry-run 或提示补齐配置。
|
||||
3. 脚本读取服务器最新版本,和本地版本对比。
|
||||
4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。
|
||||
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。
|
||||
6. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。
|
||||
7. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
|
||||
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否允许免登录检查更新、是否使用 webhook。
|
||||
6. 如果启用了免登录检查更新,灰度配置与灰度操作都不再可用。
|
||||
7. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。
|
||||
8. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
|
||||
|
||||
租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.AppEntity;
|
||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||
import com.xuqm.tenant.entity.OperationLogEntity;
|
||||
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
|
||||
import com.xuqm.tenant.dto.ServiceRequestView;
|
||||
import com.xuqm.tenant.entity.TenantEntity;
|
||||
import com.xuqm.tenant.service.FeatureServiceManager;
|
||||
import com.xuqm.tenant.entity.RiskConfigEntity;
|
||||
@ -97,7 +98,7 @@ public class OpsController {
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@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(
|
||||
"content", result.getContent(),
|
||||
"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;
|
||||
|
||||
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.FeatureServiceEntity;
|
||||
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"));
|
||||
Page<ServiceActivationRequestEntity> requests;
|
||||
if (statusStr != null && !statusStr.isEmpty()) {
|
||||
ServiceActivationRequestEntity.Status status =
|
||||
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) {
|
||||
|
||||
@ -52,6 +52,11 @@ public class AppVersionController {
|
||||
@RequestParam int currentVersionCode,
|
||||
@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
|
||||
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
||||
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
||||
@ -66,7 +71,7 @@ public class AppVersionController {
|
||||
AppVersionEntity v = latest.get();
|
||||
|
||||
// Gray release filtering
|
||||
if (v.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
||||
if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
||||
boolean inGray = false;
|
||||
if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) {
|
||||
inGray = v.getGrayMemberIds().contains(userId);
|
||||
@ -282,9 +287,12 @@ public class AppVersionController {
|
||||
|
||||
@PostMapping("/app/{id}/gray")
|
||||
public ResponseEntity<ApiResponse<AppVersionEntity>> gray(
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
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"));
|
||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
||||
entity.setGrayEnabled(enabled);
|
||||
|
||||
@ -34,9 +34,12 @@ public class PublishConfigController {
|
||||
|
||||
@GetMapping("/gray/members")
|
||||
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> listMembers(
|
||||
@RequestParam String appKey,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String groupName) {
|
||||
@RequestParam String appKey,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) String groupName) {
|
||||
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
|
||||
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
publishConfigService.listGrayMembers(appKey, keyword, groupName)));
|
||||
}
|
||||
@ -44,6 +47,9 @@ public class PublishConfigController {
|
||||
@PostMapping("/gray/members/sync")
|
||||
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> syncMembers(
|
||||
@RequestParam String appKey) {
|
||||
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
|
||||
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appKey)));
|
||||
}
|
||||
|
||||
@ -51,6 +57,9 @@ public class PublishConfigController {
|
||||
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> importMembers(
|
||||
@RequestParam String appKey,
|
||||
@RequestBody String payload) {
|
||||
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
|
||||
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appKey, payload)));
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,13 @@ public class RnBundleController {
|
||||
@RequestParam String moduleId,
|
||||
@RequestParam String platform,
|
||||
@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());
|
||||
Optional<RnBundleEntity> latest = bundleRepository
|
||||
@ -59,6 +65,18 @@ public class RnBundleController {
|
||||
|
||||
RnBundleEntity b = latest.get();
|
||||
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(
|
||||
"needsUpdate", needsUpdate,
|
||||
"bundleVersion", parseBundleVersion(b.getVersion()),
|
||||
@ -216,9 +234,12 @@ public class RnBundleController {
|
||||
|
||||
@PostMapping("/{id}/gray")
|
||||
public ResponseEntity<ApiResponse<RnBundleEntity>> gray(
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Object> body) throws Exception {
|
||||
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"));
|
||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
||||
entity.setGrayEnabled(enabled);
|
||||
|
||||
@ -315,10 +315,14 @@ public class PublishConfigService {
|
||||
|
||||
private String defaultConfigJson() {
|
||||
return """
|
||||
{"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"}
|
||||
{"allowAnonymousUpdateCheck":false,"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"}
|
||||
""".trim();
|
||||
}
|
||||
|
||||
public boolean allowAnonymousUpdateCheck(String appKey) {
|
||||
return getConfigNode(appKey).path("allowAnonymousUpdateCheck").asBoolean(false);
|
||||
}
|
||||
|
||||
public record GrayMemberGroupView(String groupName, List<GrayMemberView> members) {}
|
||||
public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {}
|
||||
private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户