feat(update): 添加 API Key 管理和 WebSocket 实时通知功能

- 新增 API Key 管理功能,支持外部工具认证调用平台 API
- 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端
- 添加 APK 文件哈希校验,支持已下载检测和直接安装
- 支持外部 APK 上传使用 API Key 认证
- 优化私有化部署自动注入 nginx WebSocket 代理配置
- 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听
这个提交包含在:
XuqmGroup 2026-06-11 12:25:16 +08:00
父节点 e3d7fbd591
当前提交 3e2db6441e
共有 31 个文件被更改,包括 1823 次插入199 次删除

查看文件

@ -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);
}
}

查看文件

@ -0,0 +1,22 @@
package com.xuqm.common.security;
/**
* API Key 验证器接口
* 各微服务实现此接口通过内部 API tenant-service 验证 API Key 的有效性
*
* 典型实现
* <pre>
* public class TenantApiKeyValidator implements ApiKeyValidator {
* public String resolveAppKey(String apiKey) {
* // 调用 tenant-service /api/internal/sdk/validate-api-key
* }
* }
* </pre>
*/
public interface ApiKeyValidator {
/**
* 验证 API Key 并返回对应的 appKey
* @return 有效的 appKey null无效/已禁用
*/
String resolveAppKey(String apiKey);
}

查看文件

@ -69,6 +69,8 @@
| App 更新检查 | 无需登录 | | App 更新检查 | 无需登录 |
| RN 更新检查 | 无需登录 | | RN 更新检查 | 无需登录 |
| Bundle 下载 | 无需登录 | | Bundle 下载 | 无需登录 |
| 更新版本上传 | `Authorization: Bearer <jwt>``X-API-Key: <api_key>` |
| 更新 WebSocket | `ws(s)://host/ws/updates?appKey=<appKey>`(无需登录) |
## 核心接口清单 ## 核心接口清单

查看文件

@ -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<ApiResponse<Map<String, Object>>> create(
@PathVariable String appKey, @RequestBody(required = false) Map<String, String> 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<ApiResponse<List<ApiKeyEntity>>> list(@PathVariable String appKey) {
return ResponseEntity.ok(ApiResponse.success(apiKeyService.listApiKeys(appKey)));
}
@PatchMapping("/{id}")
public ResponseEntity<ApiResponse<ApiKeyEntity>> setEnabled(
@PathVariable String appKey, @PathVariable String id,
@RequestBody Map<String, Object> body) {
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
return ResponseEntity.ok(ApiResponse.success(apiKeyService.setEnabled(id, enabled)));
}
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(
@PathVariable String appKey, @PathVariable String id) {
apiKeyService.deleteApiKey(id);
return ResponseEntity.ok(ApiResponse.success(null));
}
}

查看文件

@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.service.SdkAppProvisioningService; import com.xuqm.tenant.service.SdkAppProvisioningService;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.ApiKeyService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -22,14 +23,17 @@ public class InternalSdkController {
private final SdkAppProvisioningService provisioningService; private final SdkAppProvisioningService provisioningService;
private final FeatureServiceManager featureServiceManager; private final FeatureServiceManager featureServiceManager;
private final ApiKeyService apiKeyService;
@Value("${sdk.internal-token:xuqm-internal-token}") @Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken; private String internalToken;
public InternalSdkController(SdkAppProvisioningService provisioningService, public InternalSdkController(SdkAppProvisioningService provisioningService,
FeatureServiceManager featureServiceManager) { FeatureServiceManager featureServiceManager,
ApiKeyService apiKeyService) {
this.provisioningService = provisioningService; this.provisioningService = provisioningService;
this.featureServiceManager = featureServiceManager; this.featureServiceManager = featureServiceManager;
this.apiKeyService = apiKeyService;
} }
@GetMapping("/apps/{appKey}/secret") @GetMapping("/apps/{appKey}/secret")
@ -81,4 +85,27 @@ public class InternalSdkController {
"config", service.getConfig() == null ? "" : service.getConfig() "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<ApiResponse<Map<String, String>>> 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)));
}
} }

查看文件

@ -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; }
}

查看文件

@ -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<ApiKeyEntity, String> {
Optional<ApiKeyEntity> findByApiKeyAndEnabledTrue(String apiKey);
List<ApiKeyEntity> findByAppKeyOrderByCreatedAtDesc(String appKey);
long countByAppKey(String appKey);
}

查看文件

@ -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<ApiKeyEntity> 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;
}
}

查看文件

@ -642,6 +642,7 @@ public class SystemUpdateService {
emit.accept(">>> 检查并修复配置文件..."); emit.accept(">>> 检查并修复配置文件...");
patchNginxFileRoute(emit); patchNginxFileRoute(emit);
patchNginxUpdateTimeout(emit); patchNginxUpdateTimeout(emit);
patchNginxWebSocket(emit);
patchDockerComposeFileService(emit); patchDockerComposeFileService(emit);
patchDockerComposeUpdateService(emit); patchDockerComposeUpdateService(emit);
} }
@ -676,6 +677,43 @@ public class SystemUpdateService {
} }
} }
/**
* update-service 注入 nginx WebSocket 代理配置
* WebSocket 需要 HTTP/1.1 升级头和长时间超时
*/
private void patchNginxWebSocket(Consumer<String> 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<String> emit) { private void patchNginxFileRoute(Consumer<String> emit) {
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf"); Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
if (!Files.exists(conf)) return; if (!Files.exists(conf)) return;

查看文件

@ -1,5 +1,6 @@
package com.xuqm.update.config; package com.xuqm.update.config;
import com.xuqm.common.security.ApiKeyAuthFilter;
import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil; import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@ -23,9 +24,11 @@ import java.util.List;
public class SecurityConfig { public class SecurityConfig {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TenantApiKeyValidator apiKeyValidator;
public SecurityConfig(JwtUtil jwtUtil) { public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator) {
this.jwtUtil = jwtUtil; this.jwtUtil = jwtUtil;
this.apiKeyValidator = apiKeyValidator;
} }
@Bean @Bean
@ -43,13 +46,17 @@ public class SecurityConfig {
"/api/v1/rn/update/check", "/api/v1/rn/update/check",
"/api/v1/rn/inspect", "/api/v1/rn/inspect",
"/api/v1/rn/files/**", "/api/v1/rn/files/**",
"/files/apk/**" "/files/apk/**",
"/ws/updates/**"
).permitAll() ).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.exceptionHandling(ex -> ex .exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED)) .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) .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable); .formLogin(AbstractHttpConfigurer::disable);

查看文件

@ -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<String, String> 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;
}
}

查看文件

@ -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<String, String> 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;
}
}

查看文件

@ -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
}
}

查看文件

@ -22,6 +22,7 @@ import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService; import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient; import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.UpdateTenantClient; import com.xuqm.update.service.UpdateTenantClient;
import com.xuqm.update.handler.UpdateWebSocketHandler;
import com.xuqm.common.exception.BusinessException; import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.LicenseFileCrypto; import com.xuqm.common.security.LicenseFileCrypto;
@ -38,6 +39,9 @@ public class AppVersionController {
private final UpdateOperationLogService operationLogService; private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient; private final ImPushUserClient imPushUserClient;
private final UpdateTenantClient tenantClient; private final UpdateTenantClient tenantClient;
private final UpdateWebSocketHandler webSocketHandler;
private final GrayMemberService grayMemberService;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
public AppVersionController(AppVersionRepository versionRepository, public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService, UpdateAssetService updateAssetService,
@ -45,7 +49,10 @@ public class AppVersionController {
AppStoreService appStoreService, AppStoreService appStoreService,
UpdateOperationLogService operationLogService, UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient, ImPushUserClient imPushUserClient,
UpdateTenantClient tenantClient) { UpdateTenantClient tenantClient,
UpdateWebSocketHandler webSocketHandler,
GrayMemberService grayMemberService,
com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
this.versionRepository = versionRepository; this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService; this.publishConfigService = publishConfigService;
@ -53,6 +60,9 @@ public class AppVersionController {
this.operationLogService = operationLogService; this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient; this.imPushUserClient = imPushUserClient;
this.tenantClient = tenantClient; this.tenantClient = tenantClient;
this.webSocketHandler = webSocketHandler;
this.grayMemberService = grayMemberService;
this.objectMapper = objectMapper;
} }
@GetMapping("/app/check") @GetMapping("/app/check")
@ -99,16 +109,19 @@ public class AppVersionController {
String harmonyJumpUrl = hasText(v.getMarketUrl()) String harmonyJumpUrl = hasText(v.getMarketUrl())
? v.getMarketUrl() ? v.getMarketUrl()
: appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP); : appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
return ResponseEntity.ok(ApiResponse.success(Map.of( Map<String, Object> response = new java.util.LinkedHashMap<>();
"needsUpdate", true, response.put("needsUpdate", true);
"versionName", v.getVersionName(), response.put("versionName", v.getVersionName());
"versionCode", v.getVersionCode(), response.put("versionCode", v.getVersionCode());
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", response.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "");
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", response.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : "");
"forceUpdate", forcedHigher.isPresent(), response.put("forceUpdate", forcedHigher.isPresent());
"appStoreUrl", appStoreJumpUrl, response.put("appStoreUrl", appStoreJumpUrl);
"marketUrl", harmonyJumpUrl 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") @PostMapping("/app/upload")
@ -158,9 +171,15 @@ public class AppVersionController {
entity.setPlatform(platform); entity.setPlatform(platform);
entity.setVersionName(resolvedVersionName); entity.setVersionName(resolvedVersionName);
entity.setVersionCode(resolvedVersionCode); entity.setVersionCode(resolvedVersionCode);
entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID if (platform == AppVersionEntity.Platform.ANDROID) {
? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile)) if (hasText(apkUrl)) {
: null); 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.setChangeLog(changeLog);
entity.setForceUpdate(forceUpdate); entity.setForceUpdate(forceUpdate);
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
@ -259,7 +278,8 @@ public class AppVersionController {
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null); entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
AppVersionEntity saved = versionRepository.save(entity); AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record( operationLogService.record(
saved.getAppKey(), saved.getAppKey(),
@ -274,6 +294,10 @@ public class AppVersionController {
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(), "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(),
"forceUpdate", saved.isForceUpdate() "forceUpdate", saved.isForceUpdate()
)); ));
// 发布成功后发送 WebSocket 实时通知
if (saved.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) {
notifyClientsIfEnabled(saved);
}
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -360,7 +384,8 @@ public class AppVersionController {
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null); entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
} else { } else {
AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode); entity.setGrayMode(grayMode);
@ -368,35 +393,46 @@ public class AppVersionController {
case PERCENT -> { case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null); entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
} }
case IM_PUSH_USERS -> { case MEMBERS -> {
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMemberIds(null); List<String> groupNames = extractMemberIds(body.get("groupNames"));
entity.setGrayCallbackUrl(null); List<String> extraIds = extractMemberIds(body.get("extraMemberIds"));
}
case CUSTOMER_SYNC -> { // 如果配置了发布时回调先调用集成方接口同步成员
List<String> memberIds = extractMemberIds(body.get("memberIds")); String callbackUrl = publishConfigService.getPublishCallbackUrl(entity.getAppKey());
if (memberIds.isEmpty()) { if (callbackUrl != null && !callbackUrl.isBlank()) {
memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); 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.setGrayGroupNames(toJson(groupNames));
entity.setGrayCallbackUrl(null); entity.setExtraMemberIds(toJson(extraIds));
} // 解析标签 + 额外成员 最终 userId 列表
case CUSTOMER_CALLBACK -> { String resolved = grayMemberService.resolveMemberIds(
String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; entity.getAppKey(), groupNames, extraIds);
if (callbackUrl == null || callbackUrl.isBlank()) { entity.setGrayMemberIds(resolved);
throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode");
}
entity.setGrayCallbackUrl(callbackUrl);
entity.setGrayMemberIds(null);
entity.setGrayPercent(0);
} }
} }
} }
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
AppVersionEntity saved = versionRepository.save(entity); 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<List<String>>() {}).size();
} catch (Exception ignored) {}
}
operationLogService.record( operationLogService.record(
saved.getAppKey(), saved.getAppKey(),
"APP_VERSION", "APP_VERSION",
@ -407,7 +443,7 @@ public class AppVersionController {
"enabled", enabled, "enabled", enabled,
"grayMode", saved.getGrayMode().name(), "grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(), "grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() "memberCount", memberCount
)); ));
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -426,26 +462,19 @@ public class AppVersionController {
private boolean isInGrayRelease(AppVersionEntity v, String userId) { private boolean isInGrayRelease(AppVersionEntity v, String userId) {
return switch (v.getGrayMode()) { return switch (v.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent(); case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent();
case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId); case MEMBERS -> {
case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId); if (v.getGrayMemberIds() == null) yield false;
case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId); try {
List<String> ids = objectMapper.readValue(v.getGrayMemberIds(),
new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
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<String> 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") @PatchMapping("/app/{id}/changelog")
public ResponseEntity<ApiResponse<AppVersionEntity>> updateChangeLog( public ResponseEntity<ApiResponse<AppVersionEntity>> updateChangeLog(
@PathVariable String id, @PathVariable String id,
@ -508,6 +537,18 @@ public class AppVersionController {
return value != null && !value.isBlank(); 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<String> extractMemberIds(Object raw) { private List<String> extractMemberIds(Object raw) {
if (raw == null) { if (raw == null) {
return List.of(); return List.of();

查看文件

@ -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<ApiResponse<List<GrayMemberService.GrayMemberView>>> listMembers(
@RequestParam String appKey) {
return ResponseEntity.ok(ApiResponse.success(grayMemberService.listMembers(appKey)));
}
/** 手动添加成员 */
@PostMapping("/members")
public ResponseEntity<ApiResponse<Void>> addMembers(@RequestBody Map<String, Object> body) {
String appKey = (String) body.get("appKey");
@SuppressWarnings("unchecked")
List<String> userIds = (List<String>) 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<ApiResponse<Void>> deleteMember(
@RequestParam String appKey, @PathVariable String userId) {
grayMemberService.deleteMember(appKey, userId);
return ResponseEntity.ok(ApiResponse.success(null));
}
/** 同步成员(从集成方接口获取列表后调用) */
@PostMapping("/members/sync")
public ResponseEntity<ApiResponse<GrayMemberService.SyncResult>> syncMembers(
@RequestBody Map<String, Object> body) {
String appKey = (String) body.get("appKey");
@SuppressWarnings("unchecked")
List<Map<String, String>> rawMembers = (List<Map<String, String>>) body.get("members");
List<GrayMemberService.SyncMember> 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<ApiResponse<GrayMemberService.SyncResult>> importFromIm(
@RequestParam String appKey) {
return ResponseEntity.ok(ApiResponse.success(grayMemberService.importFromIm(appKey)));
}
//
// 标签管理
//
/** 列出所有标签及成员数 */
@GetMapping("/tags")
public ResponseEntity<ApiResponse<List<GrayMemberService.TagView>>> listTags(
@RequestParam String appKey) {
return ResponseEntity.ok(ApiResponse.success(grayMemberService.listTags(appKey)));
}
/** 创建标签并添加成员 */
@PostMapping("/tags")
public ResponseEntity<ApiResponse<Void>> createTag(@RequestBody Map<String, Object> body) {
String appKey = (String) body.get("appKey");
String tagName = (String) body.get("tagName");
@SuppressWarnings("unchecked")
List<String> userIds = (List<String>) body.get("userIds");
grayMemberService.createTag(appKey, tagName, userIds);
return ResponseEntity.ok(ApiResponse.success(null));
}
/** 删除标签(不删除成员) */
@DeleteMapping("/tags/{tagName}")
public ResponseEntity<ApiResponse<Void>> deleteTag(
@RequestParam String appKey, @PathVariable String tagName) {
grayMemberService.deleteTag(appKey, tagName);
return ResponseEntity.ok(ApiResponse.success(null));
}
/** 向标签添加成员 */
@PostMapping("/tags/{tagName}/members")
public ResponseEntity<ApiResponse<Void>> addMembersToTag(
@PathVariable String tagName, @RequestBody Map<String, Object> body) {
String appKey = (String) body.get("appKey");
@SuppressWarnings("unchecked")
List<String> userIds = (List<String>) body.get("userIds");
grayMemberService.addMembersToTag(appKey, tagName, userIds);
return ResponseEntity.ok(ApiResponse.success(null));
}
/** 从标签移除成员 */
@DeleteMapping("/tags/{tagName}/members")
public ResponseEntity<ApiResponse<Void>> removeMembersFromTag(
@PathVariable String tagName, @RequestBody Map<String, Object> body) {
String appKey = (String) body.get("appKey");
@SuppressWarnings("unchecked")
List<String> userIds = (List<String>) body.get("userIds");
grayMemberService.removeMembersFromTag(appKey, tagName, userIds);
return ResponseEntity.ok(ApiResponse.success(null));
}
}

查看文件

@ -6,9 +6,12 @@ import com.xuqm.update.service.PublishConfigService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 发布配置管理
* 灰度成员/标签管理已移至 [GrayMemberController]
*/
@RestController @RestController
@RequestMapping("/api/v1/updates") @RequestMapping("/api/v1/updates")
public class PublishConfigController { public class PublishConfigController {
@ -32,48 +35,15 @@ public class PublishConfigController {
return ResponseEntity.ok(ApiResponse.success(publishConfigService.saveConfig(appKey, body))); return ResponseEntity.ok(ApiResponse.success(publishConfigService.saveConfig(appKey, body)));
} }
@GetMapping("/gray/members")
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> 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<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> syncMembers(
@RequestParam String appKey) {
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
}
return ResponseEntity.ok(ApiResponse.success(publishConfigService.syncGrayMembers(appKey)));
}
@PostMapping("/gray/members/import")
public ResponseEntity<ApiResponse<List<PublishConfigService.GrayMemberGroupView>>> importMembers(
@RequestParam String appKey,
@RequestBody String payload) {
if (publishConfigService.allowAnonymousUpdateCheck(appKey)) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度成员管理");
}
return ResponseEntity.ok(ApiResponse.success(publishConfigService.replaceGrayMembers(appKey, payload)));
}
private void validateCallbacks(Map<String, Object> body) { private void validateCallbacks(Map<String, Object> body) {
if (body == null) { if (body == null) return;
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(); if (!publishUrl.isBlank() && !isHttpUrl(publishUrl)) {
String syncCallback = body.get("grayDirectorySyncCallbackUrl") == null ? "" : body.get("grayDirectorySyncCallbackUrl").toString().trim(); throw new IllegalArgumentException("publishCallbackUrl must start with http:// or https://");
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://");
} }
} }

查看文件

@ -18,6 +18,8 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.ImPushUserClient; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -32,6 +34,8 @@ public class RnBundleController {
private final PublishConfigService publishConfigService; private final PublishConfigService publishConfigService;
private final UpdateOperationLogService operationLogService; private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient; private final ImPushUserClient imPushUserClient;
private final GrayMemberService grayMemberService;
private final ObjectMapper objectMapper;
@Value("${update.base-url:https://update.dev.xuqinmin.com}") @Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl; private String baseUrl;
@ -40,12 +44,16 @@ public class RnBundleController {
UpdateAssetService updateAssetService, UpdateAssetService updateAssetService,
PublishConfigService publishConfigService, PublishConfigService publishConfigService,
UpdateOperationLogService operationLogService, UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient) { ImPushUserClient imPushUserClient,
GrayMemberService grayMemberService,
ObjectMapper objectMapper) {
this.bundleRepository = bundleRepository; this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService; this.publishConfigService = publishConfigService;
this.operationLogService = operationLogService; this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient; this.imPushUserClient = imPushUserClient;
this.grayMemberService = grayMemberService;
this.objectMapper = objectMapper;
} }
@GetMapping("/update/check") @GetMapping("/update/check")
@ -193,7 +201,8 @@ public class RnBundleController {
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null); entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
RnBundleEntity saved = bundleRepository.save(entity); RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record( operationLogService.record(
saved.getAppKey(), saved.getAppKey(),
@ -248,7 +257,8 @@ public class RnBundleController {
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null); entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
} else { } else {
RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode); entity.setGrayMode(grayMode);
@ -256,30 +266,18 @@ public class RnBundleController {
case PERCENT -> { case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null); 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<String> 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);
entity.setGrayPercent(0); entity.setGrayPercent(0);
List<String> groupNames = extractMemberIds(body.get("groupNames"));
List<String> 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(), "version", saved.getVersion(),
"grayMode", saved.getGrayMode().name(), "grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(), "grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() "memberCount", memberCount
)); ));
return ResponseEntity.ok(ApiResponse.success(saved)); return ResponseEntity.ok(ApiResponse.success(saved));
} }
@ -315,26 +313,19 @@ public class RnBundleController {
private boolean isInGrayRelease(RnBundleEntity b, String userId) { private boolean isInGrayRelease(RnBundleEntity b, String userId) {
return switch (b.getGrayMode()) { return switch (b.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent(); case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent();
case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(b.getAppKey(), userId); case MEMBERS -> {
case CUSTOMER_SYNC -> b.getGrayMemberIds() != null && b.getGrayMemberIds().contains(userId); if (b.getGrayMemberIds() == null) yield false;
case CUSTOMER_CALLBACK -> resolveRnCallbackGray(b, userId); try {
List<String> ids = objectMapper.readValue(b.getGrayMemberIds(),
new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
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<String> 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() { private String resolvePublicBaseUrl() {
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String suffix = "/api/v1/updates"; String suffix = "/api/v1/updates";

查看文件

@ -2,6 +2,8 @@ package com.xuqm.update.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@ -9,25 +11,32 @@ import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "update_gray_member", uniqueConstraints = { @Table(name = "update_gray_member", uniqueConstraints = {
@jakarta.persistence.UniqueConstraint(columnNames = {"appKey", "groupName", "userId"}) @jakarta.persistence.UniqueConstraint(columnNames = {"appKey", "userId"})
}) })
public class AppGrayMemberEntity { public class AppGrayMemberEntity {
public enum Source { SYNC, MANUAL }
@Id @Id
private String id; private String id;
@Column(nullable = false, length = 64) @Column(nullable = false, length = 64)
private String appKey; private String appKey;
@Column(length = 64) @Column(nullable = false, length = 128)
private String groupName;
@Column(nullable = false, length = 64)
private String userId; private String userId;
@Column(length = 128) @Column(length = 128)
private String name; 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) @Column(length = 512)
private String extraJson; private String extraJson;
@ -40,15 +49,18 @@ public class AppGrayMemberEntity {
public String getAppKey() { return appKey; } public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = 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 String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; } public void setUserId(String userId) { this.userId = userId; }
public String getName() { return name; } public String getName() { return name; }
public void setName(String name) { this.name = 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 String getExtraJson() { return extraJson; }
public void setExtraJson(String extraJson) { this.extraJson = extraJson; } public void setExtraJson(String extraJson) { this.extraJson = extraJson; }

查看文件

@ -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; }
}

查看文件

@ -42,8 +42,8 @@ public class AppStoreConfigEntity {
* OPPO: {"clientId":"...","clientSecret":"..."} * OPPO: {"clientId":"...","clientSecret":"..."}
* VIVO: {"accessKey":"...","accessSecret":"..."} * VIVO: {"accessKey":"...","accessSecret":"..."}
* GOOGLE_PLAY: {"serviceAccountJson":"..."} * GOOGLE_PLAY: {"serviceAccountJson":"..."}
* APP_STORE: {"marketUrl":"..."} * APP_STORE: {"issuerId":"...","keyId":"...","privateKey":"...","marketUrl":"..."}
* HARMONY_APP: {"marketUrl":"..."} * HARMONY_APP: {"clientId":"...","clientSecret":"...","marketUrl":"..."}
* REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."} * REVIEW_WEBHOOK: {"webhookUrl":"...","secret":"..."}
*/ */
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")

查看文件

@ -19,11 +19,9 @@ public class AppVersionEntity {
/** /**
* Gray release mode. * Gray release mode.
* PERCENT: deterministic hash-based percentage of all users. * PERCENT: deterministic hash-based percentage of all users.
* IM_PUSH_USERS: only users who have an IM or Push account for this appKey. * MEMBERS: specified member list (from tags, manual selection, or sync).
* 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.
*/ */
public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } public enum GrayMode { PERCENT, MEMBERS }
@Id @Id
private String id; private String id;
@ -101,13 +99,17 @@ public class AppVersionEntity {
@Column(nullable = false, length = 24) @Column(nullable = false, length = 24)
private GrayMode grayMode = GrayMode.PERCENT; 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") @Column(columnDefinition = "TEXT")
private String grayMemberIds; private String grayMemberIds;
/** Callback URL for CUSTOMER_CALLBACK gray mode. */ /** JSON array of tag names selected for gray release (for UI echo). */
@Column(length = 512) @Column(columnDefinition = "TEXT")
private String grayCallbackUrl; 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. * Pending publish plan to apply after all stores approve the current submission.
@ -126,6 +128,10 @@ public class AppVersionEntity {
@Column(length = 256) @Column(length = 256)
private String packageName; 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) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -177,6 +183,9 @@ public class AppVersionEntity {
public String getPackageName() { return packageName; } public String getPackageName() { return packageName; }
public void setPackageName(String packageName) { this.packageName = 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 LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@ -201,8 +210,11 @@ public class AppVersionEntity {
public String getGrayMemberIds() { return grayMemberIds; } public String getGrayMemberIds() { return grayMemberIds; }
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
public String getGrayCallbackUrl() { return grayCallbackUrl; } public String getGrayGroupNames() { return grayGroupNames; }
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } 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 String getPendingStorePublishType() { return pendingStorePublishType; }
public void setPendingStorePublishType(String pendingStorePublishType) { this.pendingStorePublishType = pendingStorePublishType; } public void setPendingStorePublishType(String pendingStorePublishType) { this.pendingStorePublishType = pendingStorePublishType; }

查看文件

@ -14,7 +14,7 @@ public class RnBundleEntity {
public enum Platform { ANDROID, IOS, HARMONY } public enum Platform { ANDROID, IOS, HARMONY }
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } public enum GrayMode { PERCENT, MEMBERS }
@Id @Id
private String id; private String id;
@ -69,8 +69,11 @@ public class RnBundleEntity {
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String grayMemberIds; private String grayMemberIds;
@Column(length = 512) @Column(columnDefinition = "TEXT")
private String grayCallbackUrl; private String grayGroupNames;
@Column(columnDefinition = "TEXT")
private String extraMemberIds;
@Column(nullable = false) @Column(nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -126,8 +129,11 @@ public class RnBundleEntity {
public String getGrayMemberIds() { return grayMemberIds; } public String getGrayMemberIds() { return grayMemberIds; }
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
public String getGrayCallbackUrl() { return grayCallbackUrl; } public String getGrayGroupNames() { return grayGroupNames; }
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } 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 LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -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<String, Set<WebSocketSession>> 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<WebSocketSession> 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<String, Object> payload) {
Set<WebSocketSession> set = sessions.get(appKey);
if (set == null || set.isEmpty()) return;
Map<String, Object> 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;
}
}

查看文件

@ -2,9 +2,32 @@ package com.xuqm.update.repository;
import com.xuqm.update.entity.AppGrayMemberEntity; import com.xuqm.update.entity.AppGrayMemberEntity;
import org.springframework.data.jpa.repository.JpaRepository; 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.List;
import java.util.Optional;
public interface AppGrayMemberRepository extends JpaRepository<AppGrayMemberEntity, String> { public interface AppGrayMemberRepository extends JpaRepository<AppGrayMemberEntity, String> {
List<AppGrayMemberEntity> findByAppKeyOrderByGroupNameAscNameAscUserIdAsc(String appKey);
List<AppGrayMemberEntity> findByAppKeyOrderByUserIdAsc(String appKey);
List<AppGrayMemberEntity> findByAppKeyAndActiveTrueOrderByUserIdAsc(String appKey);
Optional<AppGrayMemberEntity> findByAppKeyAndUserId(String appKey, String userId);
List<AppGrayMemberEntity> 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<String> findActiveUserIds(String appKey);
long countByAppKeyAndActiveTrue(String appKey);
} }

查看文件

@ -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<AppGrayTagEntity, String> {
List<AppGrayTagEntity> findByAppKeyAndTagName(String appKey, String tagName);
List<AppGrayTagEntity> findByAppKeyOrderByTagNameAscUserIdAsc(String appKey);
@Query("SELECT DISTINCT t.tagName FROM AppGrayTagEntity t WHERE t.appKey = ?1 ORDER BY t.tagName")
List<String> findDistinctTagNamesByAppKey(String appKey);
@Query("SELECT t.userId FROM AppGrayTagEntity t WHERE t.appKey = ?1 AND t.tagName = ?2")
List<String> findUserIdsByAppKeyAndTagName(String appKey, String tagName);
long countByAppKeyAndTagName(String appKey, String tagName);
void deleteByAppKeyAndTagName(String appKey, String tagName);
void deleteByAppKeyAndTagNameAndUserIdIn(String appKey, String tagName, List<String> userIds);
}

查看文件

@ -24,6 +24,7 @@ import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import com.xuqm.update.handler.UpdateWebSocketHandler;
@Service @Service
public class AppStoreService { public class AppStoreService {
@ -45,18 +46,24 @@ public class AppStoreService {
private final RnBundleRepository rnBundleRepository; private final RnBundleRepository rnBundleRepository;
private final UpdateOperationLogService operationLogService; private final UpdateOperationLogService operationLogService;
private final StoreReviewImNotifier storeReviewImNotifier; private final StoreReviewImNotifier storeReviewImNotifier;
private final UpdateWebSocketHandler webSocketHandler;
private final PublishConfigService publishConfigService;
private final ConcurrentMap<String, Object> versionLocks = new ConcurrentHashMap<>(); private final ConcurrentMap<String, Object> versionLocks = new ConcurrentHashMap<>();
public AppStoreService(AppStoreConfigRepository configRepo, public AppStoreService(AppStoreConfigRepository configRepo,
AppVersionRepository versionRepo, AppVersionRepository versionRepo,
RnBundleRepository rnBundleRepository, RnBundleRepository rnBundleRepository,
UpdateOperationLogService operationLogService, UpdateOperationLogService operationLogService,
StoreReviewImNotifier storeReviewImNotifier) { StoreReviewImNotifier storeReviewImNotifier,
UpdateWebSocketHandler webSocketHandler,
PublishConfigService publishConfigService) {
this.configRepo = configRepo; this.configRepo = configRepo;
this.versionRepo = versionRepo; this.versionRepo = versionRepo;
this.rnBundleRepository = rnBundleRepository; this.rnBundleRepository = rnBundleRepository;
this.operationLogService = operationLogService; this.operationLogService = operationLogService;
this.storeReviewImNotifier = storeReviewImNotifier; this.storeReviewImNotifier = storeReviewImNotifier;
this.webSocketHandler = webSocketHandler;
this.publishConfigService = publishConfigService;
} }
// Store config CRUD // Store config CRUD
@ -480,6 +487,12 @@ public class AppStoreService {
"SCHEDULE_PUBLISH", "SCHEDULE_PUBLISH",
null, null,
Map.of("publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name())); 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<RnBundleEntity> dueBundles = rnBundleRepository List<RnBundleEntity> dueBundles = rnBundleRepository

查看文件

@ -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<GrayMemberView> listMembers(String appKey) {
List<AppGrayMemberEntity> members = memberRepo.findByAppKeyOrderByUserIdAsc(appKey);
List<AppGrayTagEntity> allTags = tagRepo.findByAppKeyOrderByTagNameAscUserIdAsc(appKey);
Map<String, Set<String>> 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<String> userIds, String name) {
LocalDateTime now = LocalDateTime.now();
for (String userId : userIds) {
userId = userId.trim();
if (userId.isEmpty()) continue;
Optional<AppGrayMemberEntity> 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<AppGrayTagEntity> 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<SyncMember> members) {
LocalDateTime now = LocalDateTime.now();
int added = 0, updated = 0, removed = 0;
// 1. 标记所有 SYNC 成员为 inactive
memberRepo.deactivateAllSynced(appKey);
// 2. 遍历新列表
Set<String> seenIds = new HashSet<>();
for (SyncMember sm : members) {
String userId = sm.userId().trim();
if (userId.isEmpty() || seenIds.contains(userId)) continue;
seenIds.add(userId);
Optional<AppGrayMemberEntity> 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<AppGrayMemberEntity> 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<ImPushUserClient.ImAccount> accounts = imPushUserClient.fetchImAccounts(appKey);
List<SyncMember> members = accounts.stream()
.map(a -> new SyncMember(a.userId(), a.nickname()))
.toList();
return syncMembers(appKey, members);
}
//
// 标签管理
//
/** 列出所有标签及成员数 */
public List<TagView> listTags(String appKey) {
List<String> 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<String> userIds) {
LocalDateTime now = LocalDateTime.now();
for (String userId : userIds) {
userId = userId.trim();
if (userId.isEmpty()) continue;
// 检查是否已有此标签关系
List<AppGrayTagEntity> 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<String> userIds) {
createTag(appKey, tagName, userIds);
}
/** 从标签移除成员 */
@Transactional
public void removeMembersFromTag(String appKey, String tagName, List<String> 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<String> tagNames, List<String> extraIds) {
Set<String> 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<String, Object> 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<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> 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<SyncMember> 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<String> 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<String> tags
) {}
public record TagView(String tagName, long memberCount) {}
}

查看文件

@ -13,6 +13,9 @@ import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.util.ArrayList;
import java.util.List;
@Component @Component
public class ImPushUserClient { public class ImPushUserClient {
@ -70,4 +73,59 @@ public class ImPushUserClient {
return false; return false;
} }
} }
/**
* 获取指定应用的 IM 账号列表
* 分页获取所有账号的 userId nickname
*
* @return userId 列表
*/
public List<ImAccount> fetchImAccounts(String appKey) {
List<ImAccount> 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<JsonNode> 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) {}
} }

查看文件

@ -340,7 +340,7 @@ public class PublishConfigService {
private String defaultConfigJson() { private String defaultConfigJson() {
return """ return """
{"allowAnonymousUpdateCheck":false,"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"} {"allowAnonymousUpdateCheck":false,"enableRealtimeNotification":false,"defaultGrayPercent":0,"graySyncUrl":"","publishCallbackUrl":""}
""".trim(); """.trim();
} }
@ -348,6 +348,18 @@ public class PublishConfigService {
return getConfigNode(appKey).path("allowAnonymousUpdateCheck").asBoolean(false); 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<GrayMemberView> members) {} public record GrayMemberGroupView(String groupName, List<GrayMemberView> members) {}
public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {} public record GrayMemberView(String userId, String name, String groupName, String extraJson, String updatedAt) {}
private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {} private record GrayMemberGroupPayload(String groupName, List<GrayMemberPayload> members) {}

查看文件

@ -467,6 +467,8 @@ public class StoreSubmissionService {
case "OPPO" -> queryOppoRemoteState(v, creds); case "OPPO" -> queryOppoRemoteState(v, creds);
case "VIVO" -> queryVivoRemoteState(v, creds); case "VIVO" -> queryVivoRemoteState(v, creds);
case "HONOR" -> queryHonorRemoteState(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); default -> throw new IllegalArgumentException("Unknown store: " + storeType);
}; };
} }
@ -676,6 +678,210 @@ public class StoreSubmissionService {
true, "", sanitizeJson(body)); 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<String, String> 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<String> 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<String> 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<String, String> 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<String> 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<String, String> body = Map.of(
"grant_type", "client_credentials",
"client_id", clientId,
"client_secret", clientSecret);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> resp = rest.exchange(HARMONY_TOKEN_URL, HttpMethod.POST,
new HttpEntity<>(body, headers), Map.class);
Map<String, Object> 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. * 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) { if (mappedState != AppVersionEntity.StoreReviewState.UNDER_REVIEW) {
log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, mappedState); log.info("Store review poll: {}/{} status changed to {}", v.getId(), storeType, mappedState);
if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) { if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) {
int cmp = compareVersionCodes(polled.getOnlineVersionCode(), String.valueOf(v.getVersionCode())); // 只有确认提交版本本身已上线时才标记 APPROVED
if (polled.isCurrentSubmissionLive() || cmp >= 0) { // 旧版本上线不能证明新提交的版本已通过审核
storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(), if (polled.isCurrentSubmissionLive()) {
storeService.updateStoreReviewLive(v.getId(), storeType, false,
buildLiveReason(polled), buildExtra(polled)); buildLiveReason(polled), buildExtra(polled));
} else { } 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()); v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode());
} }
} else { } else {
@ -761,15 +968,14 @@ public class StoreSubmissionService {
} }
} else { } else {
if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) { if (polled.getReviewState() == StoreRemoteState.ReviewState.ONLINE) {
int cmp = compareVersionCodes(polled.getOnlineVersionCode(), String.valueOf(v.getVersionCode())); // 只有确认提交版本本身已上线时才标记 APPROVED
if (cmp >= 0) { if (polled.isCurrentSubmissionLive()) {
log.info("Store review poll: {}/{} was REJECTED but store has live version currentSubmissionLive={} nonCurrentRelease={} liveVersionName={} liveVersionCode={}", log.info("Store review poll: {}/{} was REJECTED but submitted version now live",
v.getId(), storeType, polled.isCurrentSubmissionLive(), polled.isNonCurrentRelease(), v.getId(), storeType);
polled.getOnlineVersionName(), polled.getOnlineVersionCode()); storeService.updateStoreReviewLive(v.getId(), storeType, false,
storeService.updateStoreReviewLive(v.getId(), storeType, !polled.isCurrentSubmissionLive(),
buildLiveReason(polled), buildExtra(polled)); buildLiveReason(polled), buildExtra(polled));
} else { } 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()); v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode());
} }
} else if ("MI".equals(storeType) } else if ("MI".equals(storeType)
@ -902,25 +1108,15 @@ public class StoreSubmissionService {
AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState()); AppVersionEntity.StoreReviewState mappedState = mapToStoreReviewState(polled.getReviewState());
if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) { if (mappedState == AppVersionEntity.StoreReviewState.APPROVED) {
if (polled.isCurrentSubmissionLive()) { 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); log.info("Manual refresh: {}/{} submitted version now live — updating", v.getId(), storeType);
storeService.updateStoreReviewLive(v.getId(), storeType, false, storeService.updateStoreReviewLive(v.getId(), storeType, false,
buildLiveReason(polled), buildExtra(polled)); buildLiveReason(polled), buildExtra(polled));
} else if (!isApproved) { } 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 log.info("Manual refresh: {}/{} online version {} != submitted {} — cannot confirm approval, keeping current state",
// APPROVED+nonCurrentRelease which would block the submission UI. v.getId(), storeType, polled.getOnlineVersionCode(), v.getVersionCode());
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());
}
} else { } else {
// Already APPROVED (from webhook): version approved but pending distribution. // Already APPROVED (from webhook): version approved but pending distribution.
// Do NOT overwrite with nonCurrentRelease=true that would show a misleading // Do NOT overwrite with nonCurrentRelease=true that would show a misleading

查看文件

@ -60,7 +60,14 @@ public class UpdateAssetService {
this.objectMapper = objectMapper; 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()) { if (apkFile == null || apkFile.isEmpty()) {
return null; return null;
} }
@ -68,8 +75,17 @@ public class UpdateAssetService {
Path dir = Paths.get(uploadDir, "apk"); Path dir = Paths.get(uploadDir, "apk");
Files.createDirectories(dir); Files.createDirectories(dir);
Path dest = dir.resolve(filename); 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 { public AppPackageInspectResult inspectAppPackage(String packageUrl) throws Exception {