From 3e2db6441e466cae0d5a23214fd32585b5efaf67 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 11 Jun 2026 12:25:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E6=B7=BB=E5=8A=A0=20API=20Key?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E5=92=8C=20WebSocket=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 API Key 管理功能,支持外部工具认证调用平台 API - 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端 - 添加 APK 文件哈希校验,支持已下载检测和直接安装 - 支持外部 APK 上传使用 API Key 认证 - 优化私有化部署自动注入 nginx WebSocket 代理配置 - 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听 --- .../common/security/ApiKeyAuthFilter.java | 60 +++ .../xuqm/common/security/ApiKeyValidator.java | 22 + docs/API_ACCESS.md | 2 + .../tenant/controller/ApiKeyController.java | 65 +++ .../controller/InternalSdkController.java | 29 +- .../com/xuqm/tenant/entity/ApiKeyEntity.java | 48 ++ .../tenant/repository/ApiKeyRepository.java | 13 + .../xuqm/tenant/service/ApiKeyService.java | 80 +++ .../tenant/service/SystemUpdateService.java | 38 ++ .../xuqm/update/config/SecurityConfig.java | 11 +- .../update/config/TenantApiKeyValidator.java | 67 +++ .../update/config/TenantAppSecretClient.java | 61 +++ .../update/config/UpdateWebSocketConfig.java | 24 + .../controller/AppVersionController.java | 151 +++--- .../controller/GrayMemberController.java | 127 +++++ .../controller/PublishConfigController.java | 52 +- .../update/controller/RnBundleController.java | 77 ++- .../update/entity/AppGrayMemberEntity.java | 28 +- .../xuqm/update/entity/AppGrayTagEntity.java | 45 ++ .../update/entity/AppStoreConfigEntity.java | 4 +- .../xuqm/update/entity/AppVersionEntity.java | 32 +- .../xuqm/update/entity/RnBundleEntity.java | 16 +- .../handler/UpdateWebSocketHandler.java | 104 ++++ .../repository/AppGrayMemberRepository.java | 25 +- .../repository/AppGrayTagRepository.java | 26 + .../xuqm/update/service/AppStoreService.java | 15 +- .../update/service/GrayMemberService.java | 458 ++++++++++++++++++ .../xuqm/update/service/ImPushUserClient.java | 58 +++ .../update/service/PublishConfigService.java | 14 +- .../service/StoreSubmissionService.java | 248 +++++++++- .../update/service/UpdateAssetService.java | 22 +- 31 files changed, 1823 insertions(+), 199 deletions(-) create mode 100644 common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java create mode 100644 common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java create mode 100644 update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java create mode 100644 update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java create mode 100644 update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/AppGrayTagEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/handler/UpdateWebSocketHandler.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/AppGrayTagRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/GrayMemberService.java diff --git a/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java new file mode 100644 index 0000000..1458d13 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java @@ -0,0 +1,60 @@ +package com.xuqm.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +/** + * API Key 认证过滤器(通用)。 + * + * 从请求头 X-API-Key 提取 API Key,通过 [ApiKeyValidator] 验证后设置 SecurityContext。 + * 各微服务只需注入对应的 ApiKeyValidator 实例即可使用。 + * + * 行为: + * - 无 X-API-Key 头 → 跳过(交给后续过滤器处理,如 JWT) + * - 有效 X-API-Key → 设置 SecurityContext(角色 ROLE_API_KEY) + * - 无效 X-API-Key → 返回 401 + */ +public class ApiKeyAuthFilter extends OncePerRequestFilter { + + public static final String HEADER_NAME = "X-API-Key"; + + private final ApiKeyValidator validator; + + public ApiKeyAuthFilter(ApiKeyValidator validator) { + this.validator = validator; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String apiKey = request.getHeader(HEADER_NAME); + if (apiKey == null || apiKey.isBlank()) { + // 无 API Key,交给后续过滤器(JWT 等) + filterChain.doFilter(request, response); + return; + } + + String appKey = validator.resolveAppKey(apiKey); + if (appKey == null) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or disabled API Key"); + return; + } + + // 认证通过 + var auth = new UsernamePasswordAuthenticationToken( + "apikey:" + appKey, null, + List.of(new SimpleGrantedAuthority("ROLE_API_KEY")) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } +} diff --git a/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java b/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java new file mode 100644 index 0000000..9eefa87 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java @@ -0,0 +1,22 @@ +package com.xuqm.common.security; + +/** + * API Key 验证器接口。 + * 各微服务实现此接口,通过内部 API 向 tenant-service 验证 API Key 的有效性。 + * + * 典型实现: + *
+ * public class TenantApiKeyValidator implements ApiKeyValidator {
+ *     public String resolveAppKey(String apiKey) {
+ *         // 调用 tenant-service 的 /api/internal/sdk/validate-api-key
+ *     }
+ * }
+ * 
+ */ +public interface ApiKeyValidator { + /** + * 验证 API Key 并返回对应的 appKey。 + * @return 有效的 appKey,或 null(无效/已禁用) + */ + String resolveAppKey(String apiKey); +} diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index c65c16e..095a335 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -69,6 +69,8 @@ | App 更新检查 | 无需登录 | | RN 更新检查 | 无需登录 | | Bundle 下载 | 无需登录 | +| 更新版本上传 | `Authorization: Bearer ` 或 `X-API-Key: ` | +| 更新 WebSocket | `ws(s)://host/ws/updates?appKey=`(无需登录) | ## 核心接口清单 diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java new file mode 100644 index 0000000..4f24cad --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java @@ -0,0 +1,65 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.ApiKeyEntity; +import com.xuqm.tenant.service.ApiKeyService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/apps/{appKey}/api-keys") +public class ApiKeyController { + + private final ApiKeyService apiKeyService; + + public ApiKeyController(ApiKeyService apiKeyService) { + this.apiKeyService = apiKeyService; + } + + /** + * 创建 API Key(需要 JWT 认证)。 + * 每个应用最多 5 个 Key。 + * 注意:API Key 仅在创建时返回完整内容,请务必保存。后续无法再次查看。 + */ + @PostMapping + public ResponseEntity>> create( + @PathVariable String appKey, @RequestBody(required = false) Map body) { + String name = body != null ? body.get("name") : null; + ApiKeyEntity created = apiKeyService.createApiKey(appKey, name); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "id", created.getId(), + "appKey", created.getAppKey(), + "apiKey", created.getApiKey(), + "name", created.getName() != null ? created.getName() : "", + "enabled", created.isEnabled(), + "createdAt", created.getCreatedAt() != null ? created.getCreatedAt().toString() : "", + "message", "请妥善保存 API Key,后续无法再次查看完整内容" + ))); + } + + /** + * 列出应用的 API Keys(已脱敏) + */ + @GetMapping + public ResponseEntity>> list(@PathVariable String appKey) { + return ResponseEntity.ok(ApiResponse.success(apiKeyService.listApiKeys(appKey))); + } + + @PatchMapping("/{id}") + public ResponseEntity> setEnabled( + @PathVariable String appKey, @PathVariable String id, + @RequestBody Map body) { + boolean enabled = Boolean.TRUE.equals(body.get("enabled")); + return ResponseEntity.ok(ApiResponse.success(apiKeyService.setEnabled(id, enabled))); + } + + @DeleteMapping("/{id}") + public ResponseEntity> delete( + @PathVariable String appKey, @PathVariable String id) { + apiKeyService.deleteApiKey(id); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java index 8a7014d..cbf12e3 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java @@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.AppEntity; import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.service.SdkAppProvisioningService; import com.xuqm.tenant.service.FeatureServiceManager; +import com.xuqm.tenant.service.ApiKeyService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -22,14 +23,17 @@ public class InternalSdkController { private final SdkAppProvisioningService provisioningService; private final FeatureServiceManager featureServiceManager; + private final ApiKeyService apiKeyService; @Value("${sdk.internal-token:xuqm-internal-token}") private String internalToken; public InternalSdkController(SdkAppProvisioningService provisioningService, - FeatureServiceManager featureServiceManager) { + FeatureServiceManager featureServiceManager, + ApiKeyService apiKeyService) { this.provisioningService = provisioningService; this.featureServiceManager = featureServiceManager; + this.apiKeyService = apiKeyService; } @GetMapping("/apps/{appKey}/secret") @@ -81,4 +85,27 @@ public class InternalSdkController { "config", service.getConfig() == null ? "" : service.getConfig() ))); } + + /** + * 内部 API:验证 API Key 有效性。 + * 供其他微服务(update/push/im/license)调用,验证请求中的 X-API-Key。 + * + * @param apiKey 待验证的 API Key + * @return 有效的 appKey,或 401 + */ + @GetMapping("/validate-api-key") + public ResponseEntity>> validateApiKey( + @RequestParam String apiKey, + @RequestHeader(value = "X-Internal-Token", required = false) String token) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403) + .body(ApiResponse.error(403, "Forbidden")); + } + String appKey = apiKeyService.validateApiKey(apiKey); + if (appKey == null) { + return ResponseEntity.status(401) + .body(ApiResponse.error(401, "Invalid or disabled API Key")); + } + return ResponseEntity.ok(ApiResponse.success(Map.of("appKey", appKey))); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java new file mode 100644 index 0000000..1ad80cb --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java @@ -0,0 +1,48 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_api_key") +public class ApiKeyEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appKey; + + @Column(nullable = false, unique = true, length = 64) + private String apiKey; + + @Column(length = 128) + private String name; + + @Column(nullable = false) + private boolean enabled = true; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppKey() { return appKey; } + public void setAppKey(String appKey) { this.appKey = appKey; } + + public String getApiKey() { return apiKey; } + public void setApiKey(String apiKey) { this.apiKey = apiKey; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java new file mode 100644 index 0000000..8bad581 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java @@ -0,0 +1,13 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.ApiKeyEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ApiKeyRepository extends JpaRepository { + Optional findByApiKeyAndEnabledTrue(String apiKey); + List findByAppKeyOrderByCreatedAtDesc(String appKey); + long countByAppKey(String appKey); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java new file mode 100644 index 0000000..be3771d --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java @@ -0,0 +1,80 @@ +package com.xuqm.tenant.service; + +import com.xuqm.tenant.entity.ApiKeyEntity; +import com.xuqm.tenant.repository.ApiKeyRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class ApiKeyService { + + public static final int MAX_KEYS_PER_APP = 8; + + private final ApiKeyRepository repository; + + public ApiKeyService(ApiKeyRepository repository) { + this.repository = repository; + } + + /** + * 创建 API Key。每个应用最多 5 个。 + * + * @return 创建的实体,包含完整 apiKey(仅此次返回明文) + */ + public ApiKeyEntity createApiKey(String appKey, String name) { + long count = repository.countByAppKey(appKey); + if (count >= MAX_KEYS_PER_APP) { + throw new IllegalStateException("每个应用最多创建 " + MAX_KEYS_PER_APP + " 个 API Key,请先删除不需要的 Key"); + } + + ApiKeyEntity entity = new ApiKeyEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(appKey); + entity.setApiKey(UUID.randomUUID().toString().replace("-", "")); + entity.setName(name != null ? name.trim() : ""); + entity.setEnabled(true); + entity.setCreatedAt(LocalDateTime.now()); + return repository.save(entity); + } + + /** + * 验证 API Key,返回对应的 appKey。无效或已禁用返回 null。 + */ + public String validateApiKey(String apiKey) { + if (apiKey == null || apiKey.isBlank()) return null; + return repository.findByApiKeyAndEnabledTrue(apiKey.trim()) + .map(ApiKeyEntity::getAppKey) + .orElse(null); + } + + /** + * 列出指定应用的所有 API Key(已脱敏)。 + */ + public List listApiKeys(String appKey) { + return repository.findByAppKeyOrderByCreatedAtDesc(appKey) + .stream() + .map(this::maskApiKey) + .toList(); + } + + public ApiKeyEntity setEnabled(String id, boolean enabled) { + ApiKeyEntity entity = repository.findById(id).orElseThrow(); + entity.setEnabled(enabled); + return maskApiKey(repository.save(entity)); + } + + public void deleteApiKey(String id) { + repository.deleteById(id); + } + + private ApiKeyEntity maskApiKey(ApiKeyEntity entity) { + String key = entity.getApiKey(); + if (key != null && key.length() > 12) { + entity.setApiKey(key.substring(0, 8) + "****" + key.substring(key.length() - 4)); + } + return entity; + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java index f653465..5400aca 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java @@ -642,6 +642,7 @@ public class SystemUpdateService { emit.accept(">>> 检查并修复配置文件..."); patchNginxFileRoute(emit); patchNginxUpdateTimeout(emit); + patchNginxWebSocket(emit); patchDockerComposeFileService(emit); patchDockerComposeUpdateService(emit); } @@ -676,6 +677,43 @@ public class SystemUpdateService { } } + /** + * 为 update-service 注入 nginx WebSocket 代理配置。 + * WebSocket 需要 HTTP/1.1 升级头和长时间超时。 + */ + private void patchNginxWebSocket(Consumer emit) { + Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); + if (!Files.exists(conf)) return; + try { + String content = Files.readString(conf); + if (content.contains("location /ws/")) return; + + String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {"; + if (!content.contains(anchor)) { + emit.accept(" [跳过] nginx WebSocket 补丁锚点未找到,请手动检查"); + return; + } + String injection = " # WebSocket 实时通知(update-service 版本发布推送)\n" + + " location /ws/ {\n" + + " set $svc update-service;\n" + + " proxy_pass http://$svc:8084;\n" + + " proxy_http_version 1.1;\n" + + " proxy_set_header Upgrade $http_upgrade;\n" + + " proxy_set_header Connection \"upgrade\";\n" + + " proxy_set_header Host $host;\n" + + " proxy_set_header X-Real-IP $remote_addr;\n" + + " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + + " proxy_read_timeout 86400s;\n" + + " proxy_send_timeout 86400s;\n" + + " }\n\n" + + anchor; + Files.writeString(conf, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING); + emit.accept(" [已修复] nginx: 补齐 /ws/ WebSocket 代理(update-service)"); + } catch (IOException e) { + emit.accept(" [警告] nginx WebSocket 修复失败: " + e.getMessage()); + } + } + private void patchNginxFileRoute(Consumer emit) { Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); if (!Files.exists(conf)) return; diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index d210fa0..02ae3c8 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.xuqm.update.config; +import com.xuqm.common.security.ApiKeyAuthFilter; import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtUtil; import org.springframework.context.annotation.Bean; @@ -23,9 +24,11 @@ import java.util.List; public class SecurityConfig { private final JwtUtil jwtUtil; + private final TenantApiKeyValidator apiKeyValidator; - public SecurityConfig(JwtUtil jwtUtil) { + public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator) { this.jwtUtil = jwtUtil; + this.apiKeyValidator = apiKeyValidator; } @Bean @@ -43,13 +46,17 @@ public class SecurityConfig { "/api/v1/rn/update/check", "/api/v1/rn/inspect", "/api/v1/rn/files/**", - "/files/apk/**" + "/files/apk/**", + "/ws/updates/**" ).permitAll() .anyRequest().authenticated() ) .exceptionHandling(ex -> ex .authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED)) ) + // API Key 认证(通用过滤器 + tenant-service 验证) + .addFilterBefore(new ApiKeyAuthFilter(apiKeyValidator), UsernamePasswordAuthenticationFilter.class) + // JWT 认证 .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable); diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java new file mode 100644 index 0000000..73d9be9 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java @@ -0,0 +1,67 @@ +package com.xuqm.update.config; + +import com.xuqm.common.security.ApiKeyValidator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 通过 tenant-service 内部 API 验证 API Key。 + */ +@Component +public class TenantApiKeyValidator implements ApiKeyValidator { + + private static final Logger log = LoggerFactory.getLogger(TenantApiKeyValidator.class); + + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") + private String tenantServiceUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** 缓存:apiKey → appKey,避免每次请求都调用 tenant-service */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + public String resolveAppKey(String apiKey) { + // 先查缓存 + String cached = cache.get(apiKey); + if (cached != null) return cached; + + try { + String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key?apiKey=" + apiKey; + var headers = new org.springframework.http.HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + var entity = new org.springframework.http.HttpEntity<>(headers); + + var response = restTemplate.exchange( + url, + org.springframework.http.HttpMethod.GET, + entity, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + JsonNode root = objectMapper.readTree(response.getBody()); + JsonNode data = root.path("data"); + String appKey = data.path("appKey").asText(null); + if (appKey != null) { + cache.put(apiKey, appKey); + } + return appKey; + } + } catch (Exception e) { + log.warn("API Key validation failed: {}", e.getMessage()); + } + return null; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java new file mode 100644 index 0000000..62472ab --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java @@ -0,0 +1,61 @@ +package com.xuqm.update.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 从 tenant-service 获取 AppSecret,用于回调签名。 + */ +@Component +public class TenantAppSecretClient { + + private static final Logger log = LoggerFactory.getLogger(TenantAppSecretClient.class); + + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") + private String tenantServiceUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** + * 获取指定应用的 AppSecret。 + * @return AppSecret,获取失败返回 null + */ + public String getAppSecret(String appKey) { + String cached = cache.get(appKey); + if (cached != null) return cached; + + try { + String url = tenantServiceUrl + "/api/internal/sdk/apps/" + appKey + "/secret"; + var headers = new org.springframework.http.HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + var entity = new org.springframework.http.HttpEntity<>(headers); + + var response = restTemplate.exchange( + url, org.springframework.http.HttpMethod.GET, entity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + JsonNode root = objectMapper.readTree(response.getBody()); + String secret = root.path("data").path("appSecret").asText(null); + if (secret != null) { + cache.put(appKey, secret); + } + return secret; + } + } catch (Exception e) { + log.warn("Failed to fetch AppSecret for {}: {}", appKey, e.getMessage()); + } + return null; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java b/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java new file mode 100644 index 0000000..033a7bc --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java @@ -0,0 +1,24 @@ +package com.xuqm.update.config; + +import com.xuqm.update.handler.UpdateWebSocketHandler; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class UpdateWebSocketConfig implements WebSocketConfigurer { + + private final UpdateWebSocketHandler handler; + + public UpdateWebSocketConfig(UpdateWebSocketHandler handler) { + this.handler = handler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(handler, "/ws/updates") + .setAllowedOrigins("*"); // WebSocket 不走 SecurityConfig 的 CORS + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index f28c00c..a4c7faa 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -22,6 +22,7 @@ import com.xuqm.update.service.PublishConfigService; import com.xuqm.update.service.AppStoreService; import com.xuqm.update.service.ImPushUserClient; import com.xuqm.update.service.UpdateTenantClient; +import com.xuqm.update.handler.UpdateWebSocketHandler; import com.xuqm.common.exception.BusinessException; import com.xuqm.common.security.LicenseFileCrypto; @@ -38,6 +39,9 @@ public class AppVersionController { private final UpdateOperationLogService operationLogService; private final ImPushUserClient imPushUserClient; private final UpdateTenantClient tenantClient; + private final UpdateWebSocketHandler webSocketHandler; + private final GrayMemberService grayMemberService; + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService, @@ -45,7 +49,10 @@ public class AppVersionController { AppStoreService appStoreService, UpdateOperationLogService operationLogService, ImPushUserClient imPushUserClient, - UpdateTenantClient tenantClient) { + UpdateTenantClient tenantClient, + UpdateWebSocketHandler webSocketHandler, + GrayMemberService grayMemberService, + com.fasterxml.jackson.databind.ObjectMapper objectMapper) { this.versionRepository = versionRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; @@ -53,6 +60,9 @@ public class AppVersionController { this.operationLogService = operationLogService; this.imPushUserClient = imPushUserClient; this.tenantClient = tenantClient; + this.webSocketHandler = webSocketHandler; + this.grayMemberService = grayMemberService; + this.objectMapper = objectMapper; } @GetMapping("/app/check") @@ -99,16 +109,19 @@ public class AppVersionController { String harmonyJumpUrl = hasText(v.getMarketUrl()) ? v.getMarketUrl() : appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); - return ResponseEntity.ok(ApiResponse.success(Map.of( - "needsUpdate", true, - "versionName", v.getVersionName(), - "versionCode", v.getVersionCode(), - "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", - "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", - "forceUpdate", forcedHigher.isPresent(), - "appStoreUrl", appStoreJumpUrl, - "marketUrl", harmonyJumpUrl - ))); + Map response = new java.util.LinkedHashMap<>(); + response.put("needsUpdate", true); + response.put("versionName", v.getVersionName()); + response.put("versionCode", v.getVersionCode()); + response.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : ""); + response.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : ""); + response.put("forceUpdate", forcedHigher.isPresent()); + response.put("appStoreUrl", appStoreJumpUrl); + response.put("marketUrl", harmonyJumpUrl); + if (v.getApkHash() != null && !v.getApkHash().isBlank()) { + response.put("apkHash", v.getApkHash()); + } + return ResponseEntity.ok(ApiResponse.success(response)); } @PostMapping("/app/upload") @@ -158,9 +171,15 @@ public class AppVersionController { entity.setPlatform(platform); entity.setVersionName(resolvedVersionName); entity.setVersionCode(resolvedVersionCode); - entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID - ? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile)) - : null); + if (platform == AppVersionEntity.Platform.ANDROID) { + if (hasText(apkUrl)) { + entity.setDownloadUrl(apkUrl); + } else { + UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile); + entity.setDownloadUrl(stored != null ? stored.url() : null); + entity.setApkHash(stored != null ? stored.hash() : null); + } + } entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); @@ -259,7 +278,8 @@ public class AppVersionController { entity.setGrayPercent(0); entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), @@ -274,6 +294,10 @@ public class AppVersionController { "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(), "forceUpdate", saved.isForceUpdate() )); + // 发布成功后发送 WebSocket 实时通知 + if (saved.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) { + notifyClientsIfEnabled(saved); + } return ResponseEntity.ok(ApiResponse.success(saved)); } @@ -360,7 +384,8 @@ public class AppVersionController { entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayPercent(0); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); } else { AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); entity.setGrayMode(grayMode); @@ -368,35 +393,46 @@ public class AppVersionController { case PERCENT -> { entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); } - case IM_PUSH_USERS -> { + case MEMBERS -> { entity.setGrayPercent(0); - entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); - } - case CUSTOMER_SYNC -> { - List memberIds = extractMemberIds(body.get("memberIds")); - if (memberIds.isEmpty()) { - memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); + List groupNames = extractMemberIds(body.get("groupNames")); + List extraIds = extractMemberIds(body.get("extraMemberIds")); + + // 如果配置了发布时回调,先调用集成方接口同步成员 + String callbackUrl = publishConfigService.getPublishCallbackUrl(entity.getAppKey()); + if (callbackUrl != null && !callbackUrl.isBlank()) { + log.info("Calling publish callback for {}: {}", entity.getAppKey(), callbackUrl); + var syncResult = grayMemberService.callPublishCallback( + entity.getAppKey(), callbackUrl, + entity.getPlatform().name(), entity.getVersionName(), + entity.getVersionCode(), entity.isForceUpdate()); + if (syncResult != null) { + log.info("Publish callback sync result: added={}, updated={}, removed={}", + syncResult.added(), syncResult.updated(), syncResult.removed()); + } } - entity.setGrayMemberIds(toJson(memberIds)); - entity.setGrayPercent(0); - entity.setGrayCallbackUrl(null); - } - case CUSTOMER_CALLBACK -> { - String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; - if (callbackUrl == null || callbackUrl.isBlank()) { - throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode"); - } - entity.setGrayCallbackUrl(callbackUrl); - entity.setGrayMemberIds(null); - entity.setGrayPercent(0); + + entity.setGrayGroupNames(toJson(groupNames)); + entity.setExtraMemberIds(toJson(extraIds)); + // 解析标签 + 额外成员 → 最终 userId 列表 + String resolved = grayMemberService.resolveMemberIds( + entity.getAppKey(), groupNames, extraIds); + entity.setGrayMemberIds(resolved); } } } entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); AppVersionEntity saved = versionRepository.save(entity); + int memberCount = 0; + if (saved.getGrayMemberIds() != null) { + try { + memberCount = objectMapper.readValue(saved.getGrayMemberIds(), + new com.fasterxml.jackson.core.type.TypeReference>() {}).size(); + } catch (Exception ignored) {} + } operationLogService.record( saved.getAppKey(), "APP_VERSION", @@ -407,7 +443,7 @@ public class AppVersionController { "enabled", enabled, "grayMode", saved.getGrayMode().name(), "grayPercent", saved.getGrayPercent(), - "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() + "memberCount", memberCount )); return ResponseEntity.ok(ApiResponse.success(saved)); } @@ -426,26 +462,19 @@ public class AppVersionController { private boolean isInGrayRelease(AppVersionEntity v, String userId) { return switch (v.getGrayMode()) { case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent(); - case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId); - case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId); - case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId); + case MEMBERS -> { + if (v.getGrayMemberIds() == null) yield false; + try { + List ids = objectMapper.readValue(v.getGrayMemberIds(), + new com.fasterxml.jackson.core.type.TypeReference>() {}); + yield ids.contains(userId); + } catch (Exception e) { + yield false; + } + } }; } - private boolean resolveCallbackGray(AppVersionEntity v, String userId) { - if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) { - return false; - } - try { - List memberIds = publishConfigService.resolveGrayMembersFromUrl( - v.getGrayCallbackUrl(), v.getAppKey(), userId); - return memberIds.contains(userId); - } catch (Exception e) { - log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage()); - return false; - } - } - @PatchMapping("/app/{id}/changelog") public ResponseEntity> updateChangeLog( @PathVariable String id, @@ -508,6 +537,18 @@ public class AppVersionController { return value != null && !value.isBlank(); } + /** + * 如果发布配置中启用了实时通知,向 WebSocket 客户端推送轻量通知。 + * 不发送版本详情,由客户端 SDK 收到通知后自动调用 checkUpdate 接口获取最新信息, + * 这样可以正确处理灰度发布等业务逻辑。 + */ + private void notifyClientsIfEnabled(AppVersionEntity v) { + if (!publishConfigService.isRealtimeNotificationEnabled(v.getAppKey())) return; + webSocketHandler.notifyVersionPublished(v.getAppKey(), Map.of( + "platform", v.getPlatform().name() + )); + } + private List extractMemberIds(Object raw) { if (raw == null) { return List.of(); diff --git a/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java b/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java new file mode 100644 index 0000000..d2eabf6 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java @@ -0,0 +1,127 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.service.GrayMemberService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 灰度成员和标签管理 API。 + */ +@RestController +@RequestMapping("/api/v1/updates/gray") +public class GrayMemberController { + + private final GrayMemberService grayMemberService; + + public GrayMemberController(GrayMemberService grayMemberService) { + this.grayMemberService = grayMemberService; + } + + // ───────────────────────────────────────────────────────────────────────── + // 成员管理 + // ───────────────────────────────────────────────────────────────────────── + + /** 列出所有成员(含标签、来源、活跃状态) */ + @GetMapping("/members") + public ResponseEntity>> listMembers( + @RequestParam String appKey) { + return ResponseEntity.ok(ApiResponse.success(grayMemberService.listMembers(appKey))); + } + + /** 手动添加成员 */ + @PostMapping("/members") + public ResponseEntity> addMembers(@RequestBody Map body) { + String appKey = (String) body.get("appKey"); + @SuppressWarnings("unchecked") + List userIds = (List) body.get("userIds"); + String name = body.get("name") instanceof String s ? s : null; + grayMemberService.addMembers(appKey, userIds, name); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** 删除成员(连同标签) */ + @DeleteMapping("/members/{userId}") + public ResponseEntity> deleteMember( + @RequestParam String appKey, @PathVariable String userId) { + grayMemberService.deleteMember(appKey, userId); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** 同步成员(从集成方接口获取列表后调用) */ + @PostMapping("/members/sync") + public ResponseEntity> syncMembers( + @RequestBody Map body) { + String appKey = (String) body.get("appKey"); + @SuppressWarnings("unchecked") + List> rawMembers = (List>) body.get("members"); + List members = rawMembers.stream() + .map(m -> new GrayMemberService.SyncMember( + m.getOrDefault("userId", ""), + m.getOrDefault("name", ""))) + .toList(); + return ResponseEntity.ok(ApiResponse.success(grayMemberService.syncMembers(appKey, members))); + } + + /** 从 IM 服务导入成员(获取 IM 账号列表并同步为灰度成员) */ + @PostMapping("/members/import-im") + public ResponseEntity> importFromIm( + @RequestParam String appKey) { + return ResponseEntity.ok(ApiResponse.success(grayMemberService.importFromIm(appKey))); + } + + // ───────────────────────────────────────────────────────────────────────── + // 标签管理 + // ───────────────────────────────────────────────────────────────────────── + + /** 列出所有标签及成员数 */ + @GetMapping("/tags") + public ResponseEntity>> listTags( + @RequestParam String appKey) { + return ResponseEntity.ok(ApiResponse.success(grayMemberService.listTags(appKey))); + } + + /** 创建标签并添加成员 */ + @PostMapping("/tags") + public ResponseEntity> createTag(@RequestBody Map body) { + String appKey = (String) body.get("appKey"); + String tagName = (String) body.get("tagName"); + @SuppressWarnings("unchecked") + List userIds = (List) body.get("userIds"); + grayMemberService.createTag(appKey, tagName, userIds); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** 删除标签(不删除成员) */ + @DeleteMapping("/tags/{tagName}") + public ResponseEntity> deleteTag( + @RequestParam String appKey, @PathVariable String tagName) { + grayMemberService.deleteTag(appKey, tagName); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** 向标签添加成员 */ + @PostMapping("/tags/{tagName}/members") + public ResponseEntity> addMembersToTag( + @PathVariable String tagName, @RequestBody Map body) { + String appKey = (String) body.get("appKey"); + @SuppressWarnings("unchecked") + List userIds = (List) body.get("userIds"); + grayMemberService.addMembersToTag(appKey, tagName, userIds); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** 从标签移除成员 */ + @DeleteMapping("/tags/{tagName}/members") + public ResponseEntity> removeMembersFromTag( + @PathVariable String tagName, @RequestBody Map body) { + String appKey = (String) body.get("appKey"); + @SuppressWarnings("unchecked") + List userIds = (List) body.get("userIds"); + grayMemberService.removeMembersFromTag(appKey, tagName, userIds); + return ResponseEntity.ok(ApiResponse.success(null)); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java b/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java index e98c97f..1a49131 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/PublishConfigController.java @@ -6,9 +6,12 @@ import com.xuqm.update.service.PublishConfigService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.Map; +/** + * 发布配置管理。 + * 灰度成员/标签管理已移至 [GrayMemberController]。 + */ @RestController @RequestMapping("/api/v1/updates") public class PublishConfigController { @@ -32,48 +35,15 @@ public class PublishConfigController { return ResponseEntity.ok(ApiResponse.success(publishConfigService.saveConfig(appKey, body))); } - @GetMapping("/gray/members") - public ResponseEntity>> listMembers( - @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))); - } - - @PostMapping("/gray/members/sync") - public ResponseEntity>> syncMembers( - @RequestParam String appKey) { - if (publishConfigService.allowAnonymousUpdateCheck(appKey)) { - throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理"); - } - return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appKey))); - } - - @PostMapping("/gray/members/import") - public ResponseEntity>> 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))); - } - private void validateCallbacks(Map body) { - if (body == null) { - return; + if (body == null) return; + String syncUrl = body.get("graySyncUrl") == null ? "" : body.get("graySyncUrl").toString().trim(); + String publishUrl = body.get("publishCallbackUrl") == null ? "" : body.get("publishCallbackUrl").toString().trim(); + if (!syncUrl.isBlank() && !isHttpUrl(syncUrl)) { + throw new IllegalArgumentException("graySyncUrl must start with http:// or https://"); } - String selectCallback = body.get("graySelectCallbackUrl") == null ? "" : body.get("graySelectCallbackUrl").toString().trim(); - String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim(); - if (!selectCallback.isBlank() && !isHttpUrl(selectCallback)) { - throw new IllegalArgumentException("graySelectCallbackUrl must start with http:// or https://"); - } - if (!syncCallback.isBlank() && !isHttpUrl(syncCallback)) { - throw new IllegalArgumentException("grayDirectorySyncCallbackUrl must start with http:// or https://"); + if (!publishUrl.isBlank() && !isHttpUrl(publishUrl)) { + throw new IllegalArgumentException("publishCallbackUrl must start with http:// or https://"); } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index 587df63..6b7ee9c 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -18,6 +18,8 @@ import java.util.Optional; import java.util.UUID; import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.ImPushUserClient; +import com.xuqm.update.service.GrayMemberService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +34,8 @@ public class RnBundleController { private final PublishConfigService publishConfigService; private final UpdateOperationLogService operationLogService; private final ImPushUserClient imPushUserClient; + private final GrayMemberService grayMemberService; + private final ObjectMapper objectMapper; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; @@ -40,12 +44,16 @@ public class RnBundleController { UpdateAssetService updateAssetService, PublishConfigService publishConfigService, UpdateOperationLogService operationLogService, - ImPushUserClient imPushUserClient) { + ImPushUserClient imPushUserClient, + GrayMemberService grayMemberService, + ObjectMapper objectMapper) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.operationLogService = operationLogService; this.imPushUserClient = imPushUserClient; + this.grayMemberService = grayMemberService; + this.objectMapper = objectMapper; } @GetMapping("/update/check") @@ -193,7 +201,8 @@ public class RnBundleController { entity.setGrayPercent(0); entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); RnBundleEntity saved = bundleRepository.save(entity); operationLogService.record( saved.getAppKey(), @@ -248,7 +257,8 @@ public class RnBundleController { entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayPercent(0); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); } else { RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); entity.setGrayMode(grayMode); @@ -256,30 +266,18 @@ public class RnBundleController { case PERCENT -> { entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); + entity.setGrayGroupNames(null); + entity.setExtraMemberIds(null); } - case IM_PUSH_USERS -> { - entity.setGrayPercent(0); - entity.setGrayMemberIds(null); - entity.setGrayCallbackUrl(null); - } - case CUSTOMER_SYNC -> { - List memberIds = extractMemberIds(body.get("memberIds")); - if (memberIds.isEmpty()) { - memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); - } - entity.setGrayMemberIds(toJson(memberIds)); - entity.setGrayPercent(0); - entity.setGrayCallbackUrl(null); - } - case CUSTOMER_CALLBACK -> { - String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; - if (callbackUrl == null || callbackUrl.isBlank()) { - throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode"); - } - entity.setGrayCallbackUrl(callbackUrl); - entity.setGrayMemberIds(null); + case MEMBERS -> { entity.setGrayPercent(0); + List groupNames = extractMemberIds(body.get("groupNames")); + List extraIds = extractMemberIds(body.get("extraMemberIds")); + entity.setGrayGroupNames(toJson(groupNames)); + entity.setExtraMemberIds(toJson(extraIds)); + String resolved = grayMemberService.resolveMemberIds( + entity.getAppKey(), groupNames, extraIds); + entity.setGrayMemberIds(resolved); } } } @@ -296,7 +294,7 @@ public class RnBundleController { "version", saved.getVersion(), "grayMode", saved.getGrayMode().name(), "grayPercent", saved.getGrayPercent(), - "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() + "memberCount", memberCount )); return ResponseEntity.ok(ApiResponse.success(saved)); } @@ -315,26 +313,19 @@ public class RnBundleController { private boolean isInGrayRelease(RnBundleEntity b, String userId) { return switch (b.getGrayMode()) { case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent(); - case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(b.getAppKey(), userId); - case CUSTOMER_SYNC -> b.getGrayMemberIds() != null && b.getGrayMemberIds().contains(userId); - case CUSTOMER_CALLBACK -> resolveRnCallbackGray(b, userId); + case MEMBERS -> { + if (b.getGrayMemberIds() == null) yield false; + try { + List ids = objectMapper.readValue(b.getGrayMemberIds(), + new com.fasterxml.jackson.core.type.TypeReference>() {}); + yield ids.contains(userId); + } catch (Exception e) { + yield false; + } + } }; } - private boolean resolveRnCallbackGray(RnBundleEntity b, String userId) { - if (b.getGrayCallbackUrl() == null || b.getGrayCallbackUrl().isBlank()) { - return false; - } - try { - List memberIds = publishConfigService.resolveGrayMembersFromUrl( - b.getGrayCallbackUrl(), b.getAppKey(), userId); - return memberIds.contains(userId); - } catch (Exception e) { - log.warn("RN gray callback failed for appKey={} bundleId={}: {}", b.getAppKey(), b.getId(), e.getMessage()); - return false; - } - } - private String resolvePublicBaseUrl() { String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; String suffix = "/api/v1/updates"; diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java index 0a8dc2b..5a125fe 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppGrayMemberEntity.java @@ -2,6 +2,8 @@ package com.xuqm.update.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Table; @@ -9,25 +11,32 @@ import java.time.LocalDateTime; @Entity @Table(name = "update_gray_member", uniqueConstraints = { - @jakarta.persistence.UniqueConstraint(columnNames = {"appKey", "groupName", "userId"}) + @jakarta.persistence.UniqueConstraint(columnNames = {"appKey", "userId"}) }) public class AppGrayMemberEntity { + public enum Source { SYNC, MANUAL } + @Id private String id; @Column(nullable = false, length = 64) private String appKey; - @Column(length = 64) - private String groupName; - - @Column(nullable = false, length = 64) + @Column(nullable = false, length = 128) private String userId; @Column(length = 128) private String name; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Source source = Source.SYNC; + + /** 同步时标记:true=在集成方列表中,false=已从集成方列表移除 */ + @Column(nullable = false) + private boolean active = true; + @Column(length = 512) private String extraJson; @@ -40,15 +49,18 @@ public class AppGrayMemberEntity { public String getAppKey() { return appKey; } public void setAppKey(String appKey) { this.appKey = appKey; } - public String getGroupName() { return groupName; } - public void setGroupName(String groupName) { this.groupName = groupName; } - public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getName() { return name; } public void setName(String name) { this.name = name; } + public Source getSource() { return source; } + public void setSource(Source source) { this.source = source; } + + public boolean isActive() { return active; } + public void setActive(boolean active) { this.active = active; } + public String getExtraJson() { return extraJson; } public void setExtraJson(String extraJson) { this.extraJson = extraJson; } diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppGrayTagEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppGrayTagEntity.java new file mode 100644 index 0000000..81b9df1 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/AppGrayTagEntity.java @@ -0,0 +1,45 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_gray_tag", uniqueConstraints = { + @jakarta.persistence.UniqueConstraint(columnNames = {"appKey", "tagName", "userId"}) +}) +public class AppGrayTagEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appKey; + + @Column(nullable = false, length = 64) + private String tagName; + + @Column(nullable = false, length = 128) + private String userId; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppKey() { return appKey; } + public void setAppKey(String appKey) { this.appKey = appKey; } + + public String getTagName() { return tagName; } + public void setTagName(String tagName) { this.tagName = tagName; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java index 70a4934..353c712 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java @@ -42,8 +42,8 @@ public class AppStoreConfigEntity { * OPPO: {"clientId":"...","clientSecret":"..."} * VIVO: {"accessKey":"...","accessSecret":"..."} * GOOGLE_PLAY: {"serviceAccountJson":"..."} - * APP_STORE: {"marketUrl":"..."} - * HARMONY_APP: {"marketUrl":"..."} + * APP_STORE: {"issuerId":"...","keyId":"...","privateKey":"...","marketUrl":"..."} + * HARMONY_APP: {"clientId":"...","clientSecret":"...","marketUrl":"..."} * REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."} */ @Column(columnDefinition = "TEXT") diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index 64183a1..69bc5c2 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -19,11 +19,9 @@ public class AppVersionEntity { /** * Gray release mode. * PERCENT: deterministic hash-based percentage of all users. - * IM_PUSH_USERS: only users who have an IM or Push account for this appKey. - * CUSTOMER_SYNC: member list synced from the customer's user server (stored in grayMemberIds). - * CUSTOMER_CALLBACK: callback to customer URL on each update check to resolve eligible members. + * MEMBERS: specified member list (from tags, manual selection, or sync). */ - public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } + public enum GrayMode { PERCENT, MEMBERS } @Id private String id; @@ -101,13 +99,17 @@ public class AppVersionEntity { @Column(nullable = false, length = 24) private GrayMode grayMode = GrayMode.PERCENT; - /** JSON array of gray member userIds (used by CUSTOMER_SYNC mode). */ + /** JSON array of gray member userIds (final resolved list for checkUpdate). */ @Column(columnDefinition = "TEXT") private String grayMemberIds; - /** Callback URL for CUSTOMER_CALLBACK gray mode. */ - @Column(length = 512) - private String grayCallbackUrl; + /** JSON array of tag names selected for gray release (for UI echo). */ + @Column(columnDefinition = "TEXT") + private String grayGroupNames; + + /** JSON array of extra userIds added manually (for UI echo). */ + @Column(columnDefinition = "TEXT") + private String extraMemberIds; /** * Pending publish plan to apply after all stores approve the current submission. @@ -126,6 +128,10 @@ public class AppVersionEntity { @Column(length = 256) private String packageName; + /** SHA-256 hash of the APK file, used for client-side download verification */ + @Column(length = 64) + private String apkHash; + @Column(nullable = false) private LocalDateTime createdAt; @@ -177,6 +183,9 @@ public class AppVersionEntity { public String getPackageName() { return packageName; } public void setPackageName(String packageName) { this.packageName = packageName; } + public String getApkHash() { return apkHash; } + public void setApkHash(String apkHash) { this.apkHash = apkHash; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } @@ -201,8 +210,11 @@ public class AppVersionEntity { public String getGrayMemberIds() { return grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } - public String getGrayCallbackUrl() { return grayCallbackUrl; } - public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } + public String getGrayGroupNames() { return grayGroupNames; } + public void setGrayGroupNames(String grayGroupNames) { this.grayGroupNames = grayGroupNames; } + + public String getExtraMemberIds() { return extraMemberIds; } + public void setExtraMemberIds(String extraMemberIds) { this.extraMemberIds = extraMemberIds; } public String getPendingStorePublishType() { return pendingStorePublishType; } public void setPendingStorePublishType(String pendingStorePublishType) { this.pendingStorePublishType = pendingStorePublishType; } diff --git a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java index 08611b2..634c9fd 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java @@ -14,7 +14,7 @@ public class RnBundleEntity { public enum Platform { ANDROID, IOS, HARMONY } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } - public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } + public enum GrayMode { PERCENT, MEMBERS } @Id private String id; @@ -69,8 +69,11 @@ public class RnBundleEntity { @Column(columnDefinition = "TEXT") private String grayMemberIds; - @Column(length = 512) - private String grayCallbackUrl; + @Column(columnDefinition = "TEXT") + private String grayGroupNames; + + @Column(columnDefinition = "TEXT") + private String extraMemberIds; @Column(nullable = false) private LocalDateTime createdAt; @@ -126,8 +129,11 @@ public class RnBundleEntity { public String getGrayMemberIds() { return grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } - public String getGrayCallbackUrl() { return grayCallbackUrl; } - public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } + public String getGrayGroupNames() { return grayGroupNames; } + public void setGrayGroupNames(String grayGroupNames) { this.grayGroupNames = grayGroupNames; } + + public String getExtraMemberIds() { return extraMemberIds; } + public void setExtraMemberIds(String extraMemberIds) { this.extraMemberIds = extraMemberIds; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } diff --git a/update-service/src/main/java/com/xuqm/update/handler/UpdateWebSocketHandler.java b/update-service/src/main/java/com/xuqm/update/handler/UpdateWebSocketHandler.java new file mode 100644 index 0000000..731a76e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/handler/UpdateWebSocketHandler.java @@ -0,0 +1,104 @@ +package com.xuqm.update.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.net.URI; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * WebSocket 处理器:管理客户端连接,并在版本发布时推送通知。 + * + * 客户端连接地址: ws(s)://{host}/ws/updates?appKey={appKey} + * 推送消息格式: {"event":"version_published","appKey":"...","versionName":"...","versionCode":123,...} + */ +@Component +public class UpdateWebSocketHandler extends TextWebSocketHandler { + + private static final Logger log = LoggerFactory.getLogger(UpdateWebSocketHandler.class); + + /** appKey → connected sessions */ + private final ConcurrentHashMap> sessions = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + String appKey = extractAppKey(session); + if (appKey == null || appKey.isBlank()) { + log.warn("WebSocket connection rejected: missing appKey parameter"); + try { session.close(CloseStatus.POLICY_VIOLATION); } catch (IOException ignored) {} + return; + } + sessions.computeIfAbsent(appKey, k -> ConcurrentHashMap.newKeySet()).add(session); + log.info("WebSocket connected: appKey={}, sessionId={}", appKey, session.getId()); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + String appKey = extractAppKey(session); + if (appKey != null) { + Set set = sessions.get(appKey); + if (set != null) { + set.remove(session); + if (set.isEmpty()) sessions.remove(appKey); + } + } + log.info("WebSocket disconnected: appKey={}, sessionId={}", appKey, session.getId()); + } + + /** + * 向指定 appKey 的所有连接推送版本发布通知。 + * 由 AppVersionController / AppStoreService 在版本发布时调用。 + */ + public void notifyVersionPublished(String appKey, Map payload) { + Set set = sessions.get(appKey); + if (set == null || set.isEmpty()) return; + + Map message = new ConcurrentHashMap<>(payload); + message.put("event", "version_published"); + message.put("appKey", appKey); + + String json; + try { + json = objectMapper.writeValueAsString(message); + } catch (Exception e) { + log.error("Failed to serialize version published notification", e); + return; + } + + TextMessage textMessage = new TextMessage(json); + for (WebSocketSession session : set) { + if (session.isOpen()) { + try { + synchronized (session) { + session.sendMessage(textMessage); + } + } catch (IOException e) { + log.warn("Failed to send WebSocket message to session {}: {}", session.getId(), e.getMessage()); + } + } + } + log.info("Notified {} clients for appKey={}", set.size(), appKey); + } + + private String extractAppKey(WebSocketSession session) { + URI uri = session.getUri(); + if (uri == null || uri.getQuery() == null) return null; + for (String param : uri.getQuery().split("&")) { + String[] kv = param.split("=", 2); + if (kv.length == 2 && "appKey".equals(kv[0])) { + return kv[1]; + } + } + return null; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java index f5a9b3b..c5bbcd7 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppGrayMemberRepository.java @@ -2,9 +2,32 @@ package com.xuqm.update.repository; import com.xuqm.update.entity.AppGrayMemberEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import java.util.List; +import java.util.Optional; public interface AppGrayMemberRepository extends JpaRepository { - List findByAppKeyOrderByGroupNameAscNameAscUserIdAsc(String appKey); + + List findByAppKeyOrderByUserIdAsc(String appKey); + + List findByAppKeyAndActiveTrueOrderByUserIdAsc(String appKey); + + Optional findByAppKeyAndUserId(String appKey, String userId); + + List findByAppKeyAndSource(String appKey, AppGrayMemberEntity.Source source); + + @Modifying + @Query("UPDATE AppGrayMemberEntity m SET m.active = false WHERE m.appKey = ?1 AND m.source = 'SYNC' AND m.active = true") + void deactivateAllSynced(String appKey); + + @Modifying + @Query("DELETE FROM AppGrayMemberEntity m WHERE m.appKey = ?1 AND m.source = 'SYNC' AND m.active = false") + void deleteInactiveSynced(String appKey); + + @Query("SELECT m.userId FROM AppGrayMemberEntity m WHERE m.appKey = ?1 AND m.active = true") + List findActiveUserIds(String appKey); + + long countByAppKeyAndActiveTrue(String appKey); } diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppGrayTagRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppGrayTagRepository.java new file mode 100644 index 0000000..b9f7e82 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/AppGrayTagRepository.java @@ -0,0 +1,26 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.AppGrayTagEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface AppGrayTagRepository extends JpaRepository { + + List findByAppKeyAndTagName(String appKey, String tagName); + + List findByAppKeyOrderByTagNameAscUserIdAsc(String appKey); + + @Query("SELECT DISTINCT t.tagName FROM AppGrayTagEntity t WHERE t.appKey = ?1 ORDER BY t.tagName") + List findDistinctTagNamesByAppKey(String appKey); + + @Query("SELECT t.userId FROM AppGrayTagEntity t WHERE t.appKey = ?1 AND t.tagName = ?2") + List findUserIdsByAppKeyAndTagName(String appKey, String tagName); + + long countByAppKeyAndTagName(String appKey, String tagName); + + void deleteByAppKeyAndTagName(String appKey, String tagName); + + void deleteByAppKeyAndTagNameAndUserIdIn(String appKey, String tagName, List userIds); +} diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index e457f29..d790854 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -24,6 +24,7 @@ import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import com.xuqm.update.handler.UpdateWebSocketHandler; @Service public class AppStoreService { @@ -45,18 +46,24 @@ public class AppStoreService { private final RnBundleRepository rnBundleRepository; private final UpdateOperationLogService operationLogService; private final StoreReviewImNotifier storeReviewImNotifier; + private final UpdateWebSocketHandler webSocketHandler; + private final PublishConfigService publishConfigService; private final ConcurrentMap versionLocks = new ConcurrentHashMap<>(); public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo, RnBundleRepository rnBundleRepository, UpdateOperationLogService operationLogService, - StoreReviewImNotifier storeReviewImNotifier) { + StoreReviewImNotifier storeReviewImNotifier, + UpdateWebSocketHandler webSocketHandler, + PublishConfigService publishConfigService) { this.configRepo = configRepo; this.versionRepo = versionRepo; this.rnBundleRepository = rnBundleRepository; this.operationLogService = operationLogService; this.storeReviewImNotifier = storeReviewImNotifier; + this.webSocketHandler = webSocketHandler; + this.publishConfigService = publishConfigService; } // ── Store config CRUD ──────────────────────────────────────────────────── @@ -480,6 +487,12 @@ public class AppStoreService { "SCHEDULE_PUBLISH", null, Map.of("publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name())); + // 定时发布后发送 WebSocket 轻量通知(客户端 SDK 自动调用 checkUpdate) + if (publishConfigService.isRealtimeNotificationEnabled(v.getAppKey())) { + webSocketHandler.notifyVersionPublished(v.getAppKey(), Map.of( + "platform", v.getPlatform().name() + )); + } } List dueBundles = rnBundleRepository diff --git a/update-service/src/main/java/com/xuqm/update/service/GrayMemberService.java b/update-service/src/main/java/com/xuqm/update/service/GrayMemberService.java new file mode 100644 index 0000000..14c20a8 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/GrayMemberService.java @@ -0,0 +1,458 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.config.TenantAppSecretClient; +import com.xuqm.update.entity.AppGrayMemberEntity; +import com.xuqm.update.entity.AppGrayTagEntity; +import com.xuqm.update.repository.AppGrayMemberRepository; +import com.xuqm.update.repository.AppGrayTagRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 灰度成员管理服务。 + * 负责成员 CRUD、同步、标签管理、成员解析。 + */ +@Service +public class GrayMemberService { + + private static final Logger log = LoggerFactory.getLogger(GrayMemberService.class); + + private final AppGrayMemberRepository memberRepo; + private final AppGrayTagRepository tagRepo; + private final ObjectMapper objectMapper; + private final TenantAppSecretClient appSecretClient; + private final ImPushUserClient imPushUserClient; + private final RestTemplate restTemplate = new RestTemplate(); + + public GrayMemberService(AppGrayMemberRepository memberRepo, + AppGrayTagRepository tagRepo, + ObjectMapper objectMapper, + TenantAppSecretClient appSecretClient, + ImPushUserClient imPushUserClient) { + this.memberRepo = memberRepo; + this.tagRepo = tagRepo; + this.objectMapper = objectMapper; + this.appSecretClient = appSecretClient; + this.imPushUserClient = imPushUserClient; + } + + // ───────────────────────────────────────────────────────────────────────── + // 成员 CRUD + // ───────────────────────────────────────────────────────────────────────── + + /** 列出所有成员(含标签信息) */ + public List listMembers(String appKey) { + List members = memberRepo.findByAppKeyOrderByUserIdAsc(appKey); + List allTags = tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey); + + Map> userTags = new HashMap<>(); + for (AppGrayTagEntity tag : allTags) { + userTags.computeIfAbsent(tag.getUserId(), k -> new LinkedHashSet<>()).add(tag.getTagName()); + } + + return members.stream().map(m -> new GrayMemberView( + m.getUserId(), + m.getName() != null ? m.getName() : "", + m.getSource().name(), + m.isActive(), + userTags.getOrDefault(m.getUserId(), Set.of()) + )).toList(); + } + + /** 手动添加成员 */ + @Transactional + public void addMembers(String appKey, List userIds, String name) { + LocalDateTime now = LocalDateTime.now(); + for (String userId : userIds) { + userId = userId.trim(); + if (userId.isEmpty()) continue; + Optional existing = memberRepo.findByAppKeyAndUserId(appKey, userId); + if (existing.isPresent()) { + AppGrayMemberEntity m = existing.get(); + m.setActive(true); + if (name != null && !name.isBlank()) m.setName(name.trim()); + m.setUpdatedAt(now); + memberRepo.save(m); + } else { + AppGrayMemberEntity m = new AppGrayMemberEntity(); + m.setId(UUID.randomUUID().toString()); + m.setAppKey(appKey); + m.setUserId(userId); + m.setName(name != null ? name.trim() : ""); + m.setSource(AppGrayMemberEntity.Source.MANUAL); + m.setActive(true); + m.setUpdatedAt(now); + memberRepo.save(m); + } + } + } + + /** 删除成员(连同标签一起清理) */ + @Transactional + public void deleteMember(String appKey, String userId) { + memberRepo.findByAppKeyAndUserId(appKey, userId).ifPresent(m -> { + memberRepo.delete(m); + // 清理该用户的所有标签 + List tags = tagRepo.findByAppKeyAndTagName(appKey, null); + tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey).stream() + .filter(t -> t.getUserId().equals(userId)) + .forEach(tagRepo::delete); + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // 同步(集成方回调 / IM 导入) + // ───────────────────────────────────────────────────────────────────────── + + /** + * 同步成员列表。保留已有标签。 + * + * 流程: + * 1. 标记所有 SYNC 成员为 inactive + * 2. 遍历新列表:已存在 → active=true;不存在 → 新增 + * 3. 删除 SYNC + inactive 的记录(从列表中移除的人) + */ + @Transactional + public SyncResult syncMembers(String appKey, List members) { + LocalDateTime now = LocalDateTime.now(); + int added = 0, updated = 0, removed = 0; + + // 1. 标记所有 SYNC 成员为 inactive + memberRepo.deactivateAllSynced(appKey); + + // 2. 遍历新列表 + Set seenIds = new HashSet<>(); + for (SyncMember sm : members) { + String userId = sm.userId().trim(); + if (userId.isEmpty() || seenIds.contains(userId)) continue; + seenIds.add(userId); + + Optional existing = memberRepo.findByAppKeyAndUserId(appKey, userId); + if (existing.isPresent()) { + AppGrayMemberEntity m = existing.get(); + m.setActive(true); + if (sm.name() != null && !sm.name().isBlank()) m.setName(sm.name()); + m.setUpdatedAt(now); + memberRepo.save(m); + updated++; + } else { + AppGrayMemberEntity m = new AppGrayMemberEntity(); + m.setId(UUID.randomUUID().toString()); + m.setAppKey(appKey); + m.setUserId(userId); + m.setName(sm.name() != null ? sm.name() : ""); + m.setSource(AppGrayMemberEntity.Source.SYNC); + m.setActive(true); + m.setUpdatedAt(now); + memberRepo.save(m); + added++; + } + } + + // 3. 删除 SYNC + inactive + List toRemove = memberRepo.findByAppKeyAndSource(appKey, AppGrayMemberEntity.Source.SYNC) + .stream().filter(m -> !m.isActive()).toList(); + removed = toRemove.size(); + memberRepo.deleteAll(toRemove); + + log.info("Gray sync for {}: added={}, updated={}, removed={}", appKey, added, updated, removed); + return new SyncResult(added, updated, removed); + } + + /** + * 从 IM 服务导入成员。 + * 获取 IM 账号列表并同步为灰度成员(无标签)。 + */ + public SyncResult importFromIm(String appKey) { + List accounts = imPushUserClient.fetchImAccounts(appKey); + List members = accounts.stream() + .map(a -> new SyncMember(a.userId(), a.nickname())) + .toList(); + return syncMembers(appKey, members); + } + + // ───────────────────────────────────────────────────────────────────────── + // 标签管理 + // ───────────────────────────────────────────────────────────────────────── + + /** 列出所有标签及成员数 */ + public List listTags(String appKey) { + List tagNames = tagRepo.findDistinctTagNamesByAppKey(appKey); + return tagNames.stream().map(name -> { + long count = tagRepo.countByAppKeyAndTagName(appKey, name); + return new TagView(name, count); + }).toList(); + } + + /** 创建标签并添加成员 */ + @Transactional + public void createTag(String appKey, String tagName, List userIds) { + LocalDateTime now = LocalDateTime.now(); + for (String userId : userIds) { + userId = userId.trim(); + if (userId.isEmpty()) continue; + // 检查是否已有此标签关系 + List existing = tagRepo.findByAppKeyAndTagName(appKey, tagName); + boolean alreadyExists = existing.stream().anyMatch(t -> t.getUserId().equals(userId)); + if (!alreadyExists) { + AppGrayTagEntity tag = new AppGrayTagEntity(); + tag.setId(UUID.randomUUID().toString()); + tag.setAppKey(appKey); + tag.setTagName(tagName.trim()); + tag.setUserId(userId); + tag.setCreatedAt(now); + tagRepo.save(tag); + } + // 确保成员存在 + ensureMemberExists(appKey, userId, now); + } + } + + /** 向标签添加成员 */ + @Transactional + public void addMembersToTag(String appKey, String tagName, List userIds) { + createTag(appKey, tagName, userIds); + } + + /** 从标签移除成员 */ + @Transactional + public void removeMembersFromTag(String appKey, String tagName, List userIds) { + tagRepo.deleteByAppKeyAndTagNameAndUserIdIn(appKey, tagName, userIds); + } + + /** 删除标签(不删除成员) */ + @Transactional + public void deleteTag(String appKey, String tagName) { + tagRepo.deleteByAppKeyAndTagName(appKey, tagName); + } + + // ───────────────────────────────────────────────────────────────────────── + // 成员解析(发版时调用) + // ───────────────────────────────────────────────────────────────────────── + + /** + * 解析灰度成员列表。 + * 将标签选择 + 额外成员合并为最终的 userId 列表。 + * + * @param appKey 应用标识 + * @param tagNames 选中的标签名列表 + * @param extraIds 额外指定的 userId 列表 + * @return 去重后的 userId 列表(JSON 字符串) + */ + public String resolveMemberIds(String appKey, List tagNames, List extraIds) { + Set all = new LinkedHashSet<>(); + + // 展开标签 + if (tagNames != null) { + for (String tag : tagNames) { + all.addAll(tagRepo.findUserIdsByAppKeyAndTagName(appKey, tag)); + } + } + + // 合并额外成员 + if (extraIds != null) { + extraIds.stream().map(String::trim).filter(s -> !s.isEmpty()).forEach(all::add); + } + + try { + return objectMapper.writeValueAsString(new ArrayList<>(all)); + } catch (Exception e) { + return "[]"; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 发布时回调 + // ───────────────────────────────────────────────────────────────────────── + + /** + * 发布灰度时回调集成方接口获取成员列表。 + * 使用 AppSecret 对请求签名。 + * + * @return 同步结果(包含新同步的成员),回调失败返回 null + */ + public SyncResult callPublishCallback(String appKey, String callbackUrl, + String platform, String versionName, + int versionCode, boolean forceUpdate) { + String appSecret = appSecretClient.getAppSecret(appKey); + if (appSecret == null) { + log.warn("Cannot call publish callback: AppSecret not found for {}", appKey); + return null; + } + + try { + long timestamp = System.currentTimeMillis() / 1000; + String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + + // 构建请求体 + Map body = new LinkedHashMap<>(); + body.put("appKey", appKey); + body.put("platform", platform); + body.put("versionName", versionName); + body.put("versionCode", versionCode); + body.put("forceUpdate", forceUpdate); + body.put("timestamp", timestamp); + + // HMAC-SHA256 签名 + String signInput = appKey + "\n" + timestamp + "\n" + nonce; + String signature = hmacSha256Hex(appSecret, signInput); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Xuqm-App-Key", appKey); + headers.set("X-Xuqm-Timestamp", String.valueOf(timestamp)); + headers.set("X-Xuqm-Nonce", nonce); + headers.set("X-Xuqm-Signature", signature); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.exchange( + callbackUrl, HttpMethod.POST, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.warn("Publish callback returned non-2xx: {}", response.getStatusCode()); + return null; + } + + // 解析响应 + return parseCallbackResponse(appKey, response.getBody()); + + } catch (Exception e) { + log.warn("Publish callback failed for {}: {}", appKey, e.getMessage()); + return null; + } + } + + /** + * 解析集成方回调响应,同步成员并可选创建标签。 + * 支持格式: + * - {"memberIds": ["id1", "id2"]} + * - {"groups": [{"tagName": "测试组", "userIds": ["id1"]}]} + * - {"members": [{"userId": "id1", "name": "张三"}]} + */ + private SyncResult parseCallbackResponse(String appKey, String responseBody) { + try { + JsonNode root = objectMapper.readTree(responseBody); + + // 收集所有成员 + List members = new ArrayList<>(); + + if (root.has("memberIds") && root.get("memberIds").isArray()) { + for (JsonNode node : root.get("memberIds")) { + String uid = node.asText(""); + if (!uid.isBlank()) members.add(new SyncMember(uid, "")); + } + } else if (root.has("members") && root.get("members").isArray()) { + for (JsonNode node : root.get("members")) { + String uid = node.path("userId").asText(""); + String name = node.path("name").asText(""); + if (!uid.isBlank()) members.add(new SyncMember(uid, name)); + } + } else if (root.has("groups") && root.get("groups").isArray()) { + // 分组格式:同步成员并创建标签 + for (JsonNode group : root.get("groups")) { + String tagName = group.path("tagName").asText(""); + if (group.has("userIds") && group.get("userIds").isArray()) { + for (JsonNode uidNode : group.get("userIds")) { + String uid = uidNode.asText(""); + if (!uid.isBlank()) members.add(new SyncMember(uid, "")); + } + } + } + } + + if (members.isEmpty()) { + log.info("Publish callback returned empty member list for {}", appKey); + return new SyncResult(0, 0, 0); + } + + // 同步成员 + SyncResult result = syncMembers(appKey, members); + + // 如果有 groups 格式,创建标签 + if (root.has("groups") && root.get("groups").isArray()) { + LocalDateTime now = LocalDateTime.now(); + for (JsonNode group : root.get("groups")) { + String tagName = group.path("tagName").asText("").trim(); + if (tagName.isBlank() || !group.has("userIds")) continue; + List userIds = new ArrayList<>(); + for (JsonNode uidNode : group.get("userIds")) { + String uid = uidNode.asText(""); + if (!uid.isBlank()) userIds.add(uid); + } + if (!userIds.isEmpty()) { + createTag(appKey, tagName, userIds); + } + } + } + + return result; + + } catch (Exception e) { + log.warn("Failed to parse publish callback response: {}", e.getMessage()); + return null; + } + } + + private String hmacSha256Hex(String key, String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException("HMAC-SHA256 failed", e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 内部方法 + // ───────────────────────────────────────────────────────────────────────── + + private void ensureMemberExists(String appKey, String userId, LocalDateTime now) { + if (memberRepo.findByAppKeyAndUserId(appKey, userId).isEmpty()) { + AppGrayMemberEntity m = new AppGrayMemberEntity(); + m.setId(UUID.randomUUID().toString()); + m.setAppKey(appKey); + m.setUserId(userId); + m.setSource(AppGrayMemberEntity.Source.SYNC); + m.setActive(true); + m.setUpdatedAt(now); + memberRepo.save(m); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 数据类型 + // ───────────────────────────────────────────────────────────────────────── + + public record SyncMember(String userId, String name) {} + + public record SyncResult(int added, int updated, int removed) {} + + public record GrayMemberView( + String userId, + String name, + String source, + boolean active, + Set tags + ) {} + + public record TagView(String tagName, long memberCount) {} +} diff --git a/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java b/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java index 1441c40..dadbba4 100644 --- a/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java +++ b/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java @@ -13,6 +13,9 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import java.util.ArrayList; +import java.util.List; + @Component public class ImPushUserClient { @@ -70,4 +73,59 @@ public class ImPushUserClient { return false; } } + + /** + * 获取指定应用的 IM 账号列表。 + * 分页获取所有账号的 userId 和 nickname。 + * + * @return userId 列表 + */ + public List fetchImAccounts(String appKey) { + List all = new ArrayList<>(); + int page = 0; + int pageSize = 100; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + + while (true) { + try { + String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl) + .path("/api/im/internal/presence/accounts") + .queryParam("appKey", appKey) + .queryParam("page", page) + .queryParam("size", pageSize) + .toUriString(); + + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class); + JsonNode body = response.getBody(); + if (body == null || body.path("code").asInt() != 200) break; + + JsonNode data = body.path("data"); + JsonNode content = data.path("content"); + if (!content.isArray() || content.isEmpty()) break; + + for (JsonNode account : content) { + String userId = account.path("userId").asText(""); + String nickname = account.path("nickname").asText(""); + if (!userId.isBlank()) { + all.add(new ImAccount(userId, nickname)); + } + } + + int totalPages = data.path("totalPages").asInt(1); + page++; + if (page >= totalPages) break; + + } catch (RestClientException e) { + log.warn("Failed to fetch IM accounts for {}: {}", appKey, e.getMessage()); + break; + } + } + + log.info("Fetched {} IM accounts for {}", all.size(), appKey); + return all; + } + + public record ImAccount(String userId, String nickname) {} } diff --git a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java index dff0b12..e20fff6 100644 --- a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java +++ b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java @@ -340,7 +340,7 @@ public class PublishConfigService { private String defaultConfigJson() { return """ - {"allowAnonymousUpdateCheck":false,"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"} + {"allowAnonymousUpdateCheck":false,"enableRealtimeNotification":false,"defaultGrayPercent":0,"graySyncUrl":"","publishCallbackUrl":""} """.trim(); } @@ -348,6 +348,18 @@ public class PublishConfigService { return getConfigNode(appKey).path("allowAnonymousUpdateCheck").asBoolean(false); } + public boolean isRealtimeNotificationEnabled(String appKey) { + return getConfigNode(appKey).path("enableRealtimeNotification").asBoolean(false); + } + + public String getPublishCallbackUrl(String appKey) { + return getConfigNode(appKey).path("publishCallbackUrl").asText(""); + } + + public String getGraySyncUrl(String appKey) { + return getConfigNode(appKey).path("graySyncUrl").asText(""); + } + public record GrayMemberGroupView(String groupName, List members) {} public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {} private record GrayMemberGroupPayload(String groupName, List members) {} diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index fea4af8..205bef6 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -467,6 +467,8 @@ public class StoreSubmissionService { case "OPPO" -> queryOppoRemoteState(v, creds); case "VIVO" -> queryVivoRemoteState(v, creds); case "HONOR" -> queryHonorRemoteState(v, creds); + case "APP_STORE" -> queryIosRemoteState(v, creds); + case "HARMONY_APP" -> queryHarmonyRemoteState(v, creds); default -> throw new IllegalArgumentException("Unknown store: " + storeType); }; } @@ -676,6 +678,210 @@ public class StoreSubmissionService { true, "", sanitizeJson(body)); } + // ── iOS App Store Connect ──────────────────────────────────────────────── + + private static final String IOS_APPSTORE_API = "https://api.appstoreconnect.apple.com/v1"; + + /** + * 查询 iOS App Store 审核状态。 + * + * 凭证格式: {"issuerId":"...","keyId":"...","privateKey":"..."} + * privateKey 为 App Store Connect API Key 的 .p8 文件内容(Base64 编码或原始 PEM)。 + */ + private StoreRemoteState queryIosRemoteState(AppVersionEntity v, Map creds) throws Exception { + String issuerId = require(creds, "issuerId", "APP_STORE"); + String keyId = require(creds, "keyId", "APP_STORE"); + String privateKey = require(creds, "privateKey", "APP_STORE"); + String bundleId = requirePackageName(v); + + String jwt = generateAppStoreJwt(issuerId, keyId, privateKey); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(jwt); + + // 1. 获取 appId + String appsUrl = IOS_APPSTORE_API + "/apps?filter[bundleId]=" + bundleId; + ResponseEntity appsResp = rest.exchange(appsUrl, HttpMethod.GET, + new HttpEntity<>(headers), String.class); + JsonNode appsRoot = objectMapper.readTree(appsResp.getBody()); + JsonNode appsData = appsRoot.path("data"); + if (!appsData.isArray() || appsData.isEmpty()) { + return StoreRemoteState.failed(AppStoreConfigEntity.StoreType.APP_STORE, + "App not found in App Store Connect", "未在 App Store Connect 中找到该应用"); + } + String appId = appsData.get(0).path("id").asText(); + + // 2. 获取 App Store Versions + String versionsUrl = IOS_APPSTORE_API + "/apps/" + appId + + "/appStoreVersions?limit=5&sort=-version"; + ResponseEntity versionsResp = rest.exchange(versionsUrl, HttpMethod.GET, + new HttpEntity<>(headers), String.class); + JsonNode versionsRoot = objectMapper.readTree(versionsResp.getBody()); + JsonNode versionsData = versionsRoot.path("data"); + + String submittedCode = String.valueOf(v.getVersionCode()); + String submittedName = v.getVersionName(); + + for (JsonNode ver : versionsData) { + String state = ver.path("attributes").path("appStoreState").asText(""); + String versionString = ver.path("attributes").path("versionString").asText(""); + + // 匹配版本号 + if (versionString.equals(submittedName) || versionString.equals(submittedCode)) { + StoreRemoteState.ReviewState reviewState = mapIosState(state); + boolean isLive = "READY_FOR_DISTRIBUTION".equals(state) + || "READY_FOR_SALE".equals(state) + || "PROCESSING_FOR_DISTRIBUTION".equals(state); + boolean currentSubmissionLive = isLive; + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.APP_STORE, + reviewState, + versionString, submittedCode, + versionString, submittedCode, + currentSubmissionLive, + false, + true, "", sanitizeJson(versionsRoot)); + } + } + + // 未找到匹配版本 + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.APP_STORE, + StoreRemoteState.ReviewState.NOT_FOUND, + "", "", + "", "", + false, false, + true, "", sanitizeJson(versionsRoot)); + } + + private StoreRemoteState.ReviewState mapIosState(String appStoreState) { + return switch (appStoreState) { + case "READY_FOR_DISTRIBUTION", "READY_FOR_SALE", "PROCESSING_FOR_DISTRIBUTION" -> + StoreRemoteState.ReviewState.ONLINE; + case "IN_REVIEW", "WAITING_FOR_REVIEW" -> + StoreRemoteState.ReviewState.UNDER_REVIEW; + case "REJECTED", "DEVELOPER_REJECTED" -> + StoreRemoteState.ReviewState.REJECTED; + default -> StoreRemoteState.ReviewState.UNKNOWN; + }; + } + + private String generateAppStoreJwt(String issuerId, String keyId, String privateKeyPem) { + try { + // 清理 PEM 格式 + String cleanPem = privateKeyPem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = java.util.Base64.getDecoder().decode(cleanPem); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("EC"); + java.security.PrivateKey privateKey = kf.generatePrivate( + new java.security.spec.PKCS8EncodedKeySpec(keyBytes)); + + long now = System.currentTimeMillis() / 1000; + String header = objectMapper.writeValueAsString(Map.of( + "alg", "ES256", "kid", keyId, "typ", "JWT")); + String payload = objectMapper.writeValueAsString(Map.of( + "iss", issuerId, + "iat", now, + "exp", now + 1200, + "aud", "appstoreconnect-v1")); + + String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String signingInput = encodedHeader + "." + encodedPayload; + + java.security.Signature sig = java.security.Signature.getInstance("SHA256withECDSA"); + sig.initSign(privateKey); + sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + byte[] signature = sig.sign(); + String encodedSig = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(signature); + + return signingInput + "." + encodedSig; + } catch (Exception e) { + throw new RuntimeException("Failed to generate App Store Connect JWT: " + e.getMessage(), e); + } + } + + // ── HarmonyOS AppGallery Connect ───────────────────────────────────────── + + private static final String HARMONY_TOKEN_URL = "https://connect-api.cloud.huawei.com/api/oauth2/v1/token"; + private static final String HARMONY_API_BASE = "https://connect-api.cloud.huawei.com/api/publish/v2"; + + /** + * 查询 HarmonyOS AppGallery 审核状态。 + * + * 凭证格式: {"clientId":"...","clientSecret":"..."} + */ + private StoreRemoteState queryHarmonyRemoteState(AppVersionEntity v, Map creds) throws Exception { + String clientId = require(creds, "clientId", "HARMONY_APP"); + String clientSecret = require(creds, "clientSecret", "HARMONY_APP"); + + String token = harmonyGetToken(clientId, clientSecret); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + + String url = HARMONY_API_BASE + "/app-info?packageName=" + requirePackageName(v); + ResponseEntity resp = rest.exchange(url, HttpMethod.GET, + new HttpEntity<>(headers), String.class); + JsonNode root = objectMapper.readTree(resp.getBody()); + JsonNode ret = root.path("ret"); + + if (!ret.isMissingNode() && ret.path("code").asInt(-1) != 0) { + return StoreRemoteState.failed(AppStoreConfigEntity.StoreType.HARMONY_APP, + ret.path("msg").asText("Unknown error"), "AppGallery 查询失败"); + } + + JsonNode appInfo = root.path("appInfo"); + int status = appInfo.path("releaseState").asInt(-1); + String onlineVersionCode = String.valueOf(appInfo.path("versionCode").asText("")); + String onlineVersionName = appInfo.path("versionName").asText(""); + String submittedCode = String.valueOf(v.getVersionCode()); + + // AppGallery releaseState: 1=已上架, 2=审核中, 3=已上架(更新), 4=审核不通过 + boolean isLive = status == 1 || status == 3; + StoreRemoteState.ReviewState reviewState; + if (status == 1 || status == 3) { + reviewState = StoreRemoteState.ReviewState.ONLINE; + } else if (status == 2) { + reviewState = StoreRemoteState.ReviewState.UNDER_REVIEW; + } else if (status == 4) { + reviewState = StoreRemoteState.ReviewState.REJECTED; + } else { + reviewState = StoreRemoteState.ReviewState.UNKNOWN; + } + + boolean currentSubmissionLive = isLive && submittedCode.equals(onlineVersionCode); + boolean nonCurrentRelease = isLive && !submittedCode.equals(onlineVersionCode) + && compareVersionCodes(onlineVersionCode, submittedCode) > 0; + return StoreRemoteState.ok( + AppStoreConfigEntity.StoreType.HARMONY_APP, + reviewState, + onlineVersionName, onlineVersionCode, + "", "", + currentSubmissionLive, + nonCurrentRelease, + true, "", sanitizeJson(root)); + } + + private String harmonyGetToken(String clientId, String clientSecret) throws Exception { + Map body = Map.of( + "grant_type", "client_credentials", + "client_id", clientId, + "client_secret", clientSecret); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity resp = rest.exchange(HARMONY_TOKEN_URL, HttpMethod.POST, + new HttpEntity<>(body, headers), Map.class); + Map respBody = resp.getBody(); + if (respBody == null || respBody.get("access_token") == null) { + throw new RuntimeException("AppGallery token request failed: " + respBody); + } + return (String) respBody.get("access_token"); + } + /** * Poll vendor APIs every 10 minutes for versions with UNDER_REVIEW or REJECTED stores. * @@ -745,12 +951,13 @@ public class StoreSubmissionService { if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) { log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, mappedState); if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) { - int cmp = compareVersionCodes(polled.getOnlineVersionCode(), String.valueOf(v.getVersionCode())); - if (polled.isCurrentSubmissionLive() || cmp >= 0) { - storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), + // 只有确认提交版本本身已上线时才标记 APPROVED。 + // 旧版本上线不能证明新提交的版本已通过审核。 + if (polled.isCurrentSubmissionLive()) { + storeService.updateStoreReviewLive(v.getId(), storeType, false, buildLiveReason(polled), buildExtra(polled)); } else { - log.debug("Store review poll: {}/{} online {} < submitted {} — keeping current state", + log.info("Store review poll: {}/{} online version {} != submitted {} — keeping UNDER_REVIEW (cannot confirm submitted version is approved)", v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode()); } } else { @@ -761,15 +968,14 @@ public class StoreSubmissionService { } } else { if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) { - int cmp = compareVersionCodes(polled.getOnlineVersionCode(), String.valueOf(v.getVersionCode())); - if (cmp >= 0) { - log.info("Store review poll: {}/{} was REJECTED but store has live version currentSubmissionLive={} nonCurrentRelease={} liveVersionName={} liveVersionCode={}", - v.getId(), storeType, polled.isCurrentSubmissionLive(), polled.isNonCurrentRelease(), - polled.getOnlineVersionName(), polled.getOnlineVersionCode()); - storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), + // 只有确认提交版本本身已上线时才标记 APPROVED + if (polled.isCurrentSubmissionLive()) { + log.info("Store review poll: {}/{} was REJECTED but submitted version now live", + v.getId(), storeType); + storeService.updateStoreReviewLive(v.getId(), storeType, false, buildLiveReason(polled), buildExtra(polled)); } else { - log.debug("Store review poll: {}/{} was REJECTED but online {} < submitted {} — not marking pre-existing", + log.info("Store review poll: {}/{} was REJECTED, store online version {} != submitted {} — keeping REJECTED state", v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode()); } } else if ("MI".equals(storeType) @@ -902,25 +1108,15 @@ public class StoreSubmissionService { AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState()); if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) { if (polled.isCurrentSubmissionLive()) { - // Submitted version is now live — update with currentSubmissionLive=true + // 提交版本本身已上线 — 标记 APPROVED log.info("Manual refresh: {}/{} submitted version now live — updating", v.getId(), storeType); storeService.updateStoreReviewLive(v.getId(), storeType, false, buildLiveReason(polled), buildExtra(polled)); } else if (!isApproved) { - // Store has a different live version (preExisting) and we didn't have APPROVED yet. - // Only mark as pre-existing when the online version is >= submitted version. - // If online < submitted, this is a normal new-release scenario — do not write - // APPROVED+nonCurrentRelease which would block the submission UI. - int cmp = compareVersionCodes(polled.getOnlineVersionCode(), String.valueOf(v.getVersionCode())); - if (cmp >= 0) { - log.info("Manual refresh: {}/{} pre-existing live detected currentSubmissionLive={} liveVersionCode={}", - v.getId(), storeType, false, polled.getOnlineVersionCode()); - storeService.updateStoreReviewLive(v.getId(), storeType, true, - buildLiveReason(polled), buildExtra(polled)); - } else { - log.info("Manual refresh: {}/{} online version {} < submitted {} — normal new release, skipping pre-existing mark", - v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode()); - } + // 商店有其他版本在线,但不是本次提交的版本。 + // 旧版本在线不能证明新版本已通过审核,保持当前状态不变。 + log.info("Manual refresh: {}/{} online version {} != submitted {} — cannot confirm approval, keeping current state", + v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode()); } else { // Already APPROVED (from webhook): version approved but pending distribution. // Do NOT overwrite with nonCurrentRelease=true — that would show a misleading diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index 2dbbe35..e24d8be 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -60,7 +60,14 @@ public class UpdateAssetService { this.objectMapper = objectMapper; } - public String storeAppPackage(MultipartFile apkFile) throws IOException { + /** 存储结果:下载 URL 和文件 SHA-256 哈希 */ + public record StoreResult(String url, String hash) {} + + /** + * 存储 APK 文件并计算 SHA-256 哈希。 + * 哈希用于客户端下载后校验文件完整性。 + */ + public StoreResult storeAppPackage(MultipartFile apkFile) throws IOException { if (apkFile == null || apkFile.isEmpty()) { return null; } @@ -68,8 +75,17 @@ public class UpdateAssetService { Path dir = Paths.get(uploadDir, "apk"); Files.createDirectories(dir); Path dest = dir.resolve(filename); - apkFile.transferTo(dest.toFile()); - return baseUrl + "/files/apk/" + filename; + + // 计算 SHA-256 哈希的同时写入文件 + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream in = apkFile.getInputStream(); + DigestInputStream dis = new DigestInputStream(in, digest)) { + Files.copy(dis, dest, StandardCopyOption.REPLACE_EXISTING); + } + String hash = HexFormat.of().formatHex(digest.digest()); + + String url = baseUrl + "/files/apk/" + filename; + return new StoreResult(url, hash); } public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception {