diff --git a/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java
new file mode 100644
index 0000000..1458d13
--- /dev/null
+++ b/common/src/main/java/com/xuqm/common/security/ApiKeyAuthFilter.java
@@ -0,0 +1,60 @@
+package com.xuqm.common.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * API Key 认证过滤器(通用)。
+ *
+ * 从请求头 X-API-Key 提取 API Key,通过 [ApiKeyValidator] 验证后设置 SecurityContext。
+ * 各微服务只需注入对应的 ApiKeyValidator 实例即可使用。
+ *
+ * 行为:
+ * - 无 X-API-Key 头 → 跳过(交给后续过滤器处理,如 JWT)
+ * - 有效 X-API-Key → 设置 SecurityContext(角色 ROLE_API_KEY)
+ * - 无效 X-API-Key → 返回 401
+ */
+public class ApiKeyAuthFilter extends OncePerRequestFilter {
+
+ public static final String HEADER_NAME = "X-API-Key";
+
+ private final ApiKeyValidator validator;
+
+ public ApiKeyAuthFilter(ApiKeyValidator validator) {
+ this.validator = validator;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ String apiKey = request.getHeader(HEADER_NAME);
+ if (apiKey == null || apiKey.isBlank()) {
+ // 无 API Key,交给后续过滤器(JWT 等)
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ String appKey = validator.resolveAppKey(apiKey);
+ if (appKey == null) {
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid or disabled API Key");
+ return;
+ }
+
+ // 认证通过
+ var auth = new UsernamePasswordAuthenticationToken(
+ "apikey:" + appKey, null,
+ List.of(new SimpleGrantedAuthority("ROLE_API_KEY"))
+ );
+ SecurityContextHolder.getContext().setAuthentication(auth);
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java b/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java
new file mode 100644
index 0000000..9eefa87
--- /dev/null
+++ b/common/src/main/java/com/xuqm/common/security/ApiKeyValidator.java
@@ -0,0 +1,22 @@
+package com.xuqm.common.security;
+
+/**
+ * API Key 验证器接口。
+ * 各微服务实现此接口,通过内部 API 向 tenant-service 验证 API Key 的有效性。
+ *
+ * 典型实现:
+ *
+ * public class TenantApiKeyValidator implements ApiKeyValidator {
+ * public String resolveAppKey(String apiKey) {
+ * // 调用 tenant-service 的 /api/internal/sdk/validate-api-key
+ * }
+ * }
+ *
+ */
+public interface ApiKeyValidator {
+ /**
+ * 验证 API Key 并返回对应的 appKey。
+ * @return 有效的 appKey,或 null(无效/已禁用)
+ */
+ String resolveAppKey(String apiKey);
+}
diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md
index c65c16e..095a335 100644
--- a/docs/API_ACCESS.md
+++ b/docs/API_ACCESS.md
@@ -69,6 +69,8 @@
| App 更新检查 | 无需登录 |
| RN 更新检查 | 无需登录 |
| Bundle 下载 | 无需登录 |
+| 更新版本上传 | `Authorization: Bearer ` 或 `X-API-Key: ` |
+| 更新 WebSocket | `ws(s)://host/ws/updates?appKey=`(无需登录) |
## 核心接口清单
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java
new file mode 100644
index 0000000..4f24cad
--- /dev/null
+++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/ApiKeyController.java
@@ -0,0 +1,65 @@
+package com.xuqm.tenant.controller;
+
+import com.xuqm.common.model.ApiResponse;
+import com.xuqm.tenant.entity.ApiKeyEntity;
+import com.xuqm.tenant.service.ApiKeyService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/apps/{appKey}/api-keys")
+public class ApiKeyController {
+
+ private final ApiKeyService apiKeyService;
+
+ public ApiKeyController(ApiKeyService apiKeyService) {
+ this.apiKeyService = apiKeyService;
+ }
+
+ /**
+ * 创建 API Key(需要 JWT 认证)。
+ * 每个应用最多 5 个 Key。
+ * 注意:API Key 仅在创建时返回完整内容,请务必保存。后续无法再次查看。
+ */
+ @PostMapping
+ public ResponseEntity>> create(
+ @PathVariable String appKey, @RequestBody(required = false) Map body) {
+ String name = body != null ? body.get("name") : null;
+ ApiKeyEntity created = apiKeyService.createApiKey(appKey, name);
+ return ResponseEntity.ok(ApiResponse.success(Map.of(
+ "id", created.getId(),
+ "appKey", created.getAppKey(),
+ "apiKey", created.getApiKey(),
+ "name", created.getName() != null ? created.getName() : "",
+ "enabled", created.isEnabled(),
+ "createdAt", created.getCreatedAt() != null ? created.getCreatedAt().toString() : "",
+ "message", "请妥善保存 API Key,后续无法再次查看完整内容"
+ )));
+ }
+
+ /**
+ * 列出应用的 API Keys(已脱敏)
+ */
+ @GetMapping
+ public ResponseEntity>> list(@PathVariable String appKey) {
+ return ResponseEntity.ok(ApiResponse.success(apiKeyService.listApiKeys(appKey)));
+ }
+
+ @PatchMapping("/{id}")
+ public ResponseEntity> setEnabled(
+ @PathVariable String appKey, @PathVariable String id,
+ @RequestBody Map body) {
+ boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
+ return ResponseEntity.ok(ApiResponse.success(apiKeyService.setEnabled(id, enabled)));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity> delete(
+ @PathVariable String appKey, @PathVariable String id) {
+ apiKeyService.deleteApiKey(id);
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+}
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java
index 8a7014d..cbf12e3 100644
--- a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java
+++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalSdkController.java
@@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.service.SdkAppProvisioningService;
import com.xuqm.tenant.service.FeatureServiceManager;
+import com.xuqm.tenant.service.ApiKeyService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@@ -22,14 +23,17 @@ public class InternalSdkController {
private final SdkAppProvisioningService provisioningService;
private final FeatureServiceManager featureServiceManager;
+ private final ApiKeyService apiKeyService;
@Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalSdkController(SdkAppProvisioningService provisioningService,
- FeatureServiceManager featureServiceManager) {
+ FeatureServiceManager featureServiceManager,
+ ApiKeyService apiKeyService) {
this.provisioningService = provisioningService;
this.featureServiceManager = featureServiceManager;
+ this.apiKeyService = apiKeyService;
}
@GetMapping("/apps/{appKey}/secret")
@@ -81,4 +85,27 @@ public class InternalSdkController {
"config", service.getConfig() == null ? "" : service.getConfig()
)));
}
+
+ /**
+ * 内部 API:验证 API Key 有效性。
+ * 供其他微服务(update/push/im/license)调用,验证请求中的 X-API-Key。
+ *
+ * @param apiKey 待验证的 API Key
+ * @return 有效的 appKey,或 401
+ */
+ @GetMapping("/validate-api-key")
+ public ResponseEntity>> validateApiKey(
+ @RequestParam String apiKey,
+ @RequestHeader(value = "X-Internal-Token", required = false) String token) {
+ if (token == null || !internalToken.equals(token)) {
+ return ResponseEntity.status(403)
+ .body(ApiResponse.error(403, "Forbidden"));
+ }
+ String appKey = apiKeyService.validateApiKey(apiKey);
+ if (appKey == null) {
+ return ResponseEntity.status(401)
+ .body(ApiResponse.error(401, "Invalid or disabled API Key"));
+ }
+ return ResponseEntity.ok(ApiResponse.success(Map.of("appKey", appKey)));
+ }
}
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java
new file mode 100644
index 0000000..1ad80cb
--- /dev/null
+++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/ApiKeyEntity.java
@@ -0,0 +1,48 @@
+package com.xuqm.tenant.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "t_api_key")
+public class ApiKeyEntity {
+
+ @Id
+ private String id;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(nullable = false, unique = true, length = 64)
+ private String apiKey;
+
+ @Column(length = 128)
+ private String name;
+
+ @Column(nullable = false)
+ private boolean enabled = true;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ public String getId() { return id; }
+ public void setId(String id) { this.id = id; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getApiKey() { return apiKey; }
+ public void setApiKey(String apiKey) { this.apiKey = apiKey; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java
new file mode 100644
index 0000000..8bad581
--- /dev/null
+++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/ApiKeyRepository.java
@@ -0,0 +1,13 @@
+package com.xuqm.tenant.repository;
+
+import com.xuqm.tenant.entity.ApiKeyEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface ApiKeyRepository extends JpaRepository {
+ Optional findByApiKeyAndEnabledTrue(String apiKey);
+ List findByAppKeyOrderByCreatedAtDesc(String appKey);
+ long countByAppKey(String appKey);
+}
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java
new file mode 100644
index 0000000..be3771d
--- /dev/null
+++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ApiKeyService.java
@@ -0,0 +1,80 @@
+package com.xuqm.tenant.service;
+
+import com.xuqm.tenant.entity.ApiKeyEntity;
+import com.xuqm.tenant.repository.ApiKeyRepository;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+
+@Service
+public class ApiKeyService {
+
+ public static final int MAX_KEYS_PER_APP = 8;
+
+ private final ApiKeyRepository repository;
+
+ public ApiKeyService(ApiKeyRepository repository) {
+ this.repository = repository;
+ }
+
+ /**
+ * 创建 API Key。每个应用最多 5 个。
+ *
+ * @return 创建的实体,包含完整 apiKey(仅此次返回明文)
+ */
+ public ApiKeyEntity createApiKey(String appKey, String name) {
+ long count = repository.countByAppKey(appKey);
+ if (count >= MAX_KEYS_PER_APP) {
+ throw new IllegalStateException("每个应用最多创建 " + MAX_KEYS_PER_APP + " 个 API Key,请先删除不需要的 Key");
+ }
+
+ ApiKeyEntity entity = new ApiKeyEntity();
+ entity.setId(UUID.randomUUID().toString());
+ entity.setAppKey(appKey);
+ entity.setApiKey(UUID.randomUUID().toString().replace("-", ""));
+ entity.setName(name != null ? name.trim() : "");
+ entity.setEnabled(true);
+ entity.setCreatedAt(LocalDateTime.now());
+ return repository.save(entity);
+ }
+
+ /**
+ * 验证 API Key,返回对应的 appKey。无效或已禁用返回 null。
+ */
+ public String validateApiKey(String apiKey) {
+ if (apiKey == null || apiKey.isBlank()) return null;
+ return repository.findByApiKeyAndEnabledTrue(apiKey.trim())
+ .map(ApiKeyEntity::getAppKey)
+ .orElse(null);
+ }
+
+ /**
+ * 列出指定应用的所有 API Key(已脱敏)。
+ */
+ public List listApiKeys(String appKey) {
+ return repository.findByAppKeyOrderByCreatedAtDesc(appKey)
+ .stream()
+ .map(this::maskApiKey)
+ .toList();
+ }
+
+ public ApiKeyEntity setEnabled(String id, boolean enabled) {
+ ApiKeyEntity entity = repository.findById(id).orElseThrow();
+ entity.setEnabled(enabled);
+ return maskApiKey(repository.save(entity));
+ }
+
+ public void deleteApiKey(String id) {
+ repository.deleteById(id);
+ }
+
+ private ApiKeyEntity maskApiKey(ApiKeyEntity entity) {
+ String key = entity.getApiKey();
+ if (key != null && key.length() > 12) {
+ entity.setApiKey(key.substring(0, 8) + "****" + key.substring(key.length() - 4));
+ }
+ return entity;
+ }
+}
diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java
index f653465..5400aca 100644
--- a/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java
+++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SystemUpdateService.java
@@ -642,6 +642,7 @@ public class SystemUpdateService {
emit.accept(">>> 检查并修复配置文件...");
patchNginxFileRoute(emit);
patchNginxUpdateTimeout(emit);
+ patchNginxWebSocket(emit);
patchDockerComposeFileService(emit);
patchDockerComposeUpdateService(emit);
}
@@ -676,6 +677,43 @@ public class SystemUpdateService {
}
}
+ /**
+ * 为 update-service 注入 nginx WebSocket 代理配置。
+ * WebSocket 需要 HTTP/1.1 升级头和长时间超时。
+ */
+ private void patchNginxWebSocket(Consumer emit) {
+ Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
+ if (!Files.exists(conf)) return;
+ try {
+ String content = Files.readString(conf);
+ if (content.contains("location /ws/")) return;
+
+ String anchor = " # 核心 API(兜底,在所有具体 /api/xxx/ 之后)\n location /api/ {";
+ if (!content.contains(anchor)) {
+ emit.accept(" [跳过] nginx WebSocket 补丁锚点未找到,请手动检查");
+ return;
+ }
+ String injection = " # WebSocket 实时通知(update-service 版本发布推送)\n"
+ + " location /ws/ {\n"
+ + " set $svc update-service;\n"
+ + " proxy_pass http://$svc:8084;\n"
+ + " proxy_http_version 1.1;\n"
+ + " proxy_set_header Upgrade $http_upgrade;\n"
+ + " proxy_set_header Connection \"upgrade\";\n"
+ + " proxy_set_header Host $host;\n"
+ + " proxy_set_header X-Real-IP $remote_addr;\n"
+ + " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
+ + " proxy_read_timeout 86400s;\n"
+ + " proxy_send_timeout 86400s;\n"
+ + " }\n\n"
+ + anchor;
+ Files.writeString(conf, content.replace(anchor, injection), StandardOpenOption.TRUNCATE_EXISTING);
+ emit.accept(" [已修复] nginx: 补齐 /ws/ WebSocket 代理(update-service)");
+ } catch (IOException e) {
+ emit.accept(" [警告] nginx WebSocket 修复失败: " + e.getMessage());
+ }
+ }
+
private void patchNginxFileRoute(Consumer emit) {
Path conf = Paths.get(deployRoot, "config", "nginx", "conf.d", "xuqm.conf");
if (!Files.exists(conf)) return;
diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java
index d210fa0..02ae3c8 100644
--- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java
+++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java
@@ -1,5 +1,6 @@
package com.xuqm.update.config;
+import com.xuqm.common.security.ApiKeyAuthFilter;
import com.xuqm.common.security.JwtAuthFilter;
import com.xuqm.common.security.JwtUtil;
import org.springframework.context.annotation.Bean;
@@ -23,9 +24,11 @@ import java.util.List;
public class SecurityConfig {
private final JwtUtil jwtUtil;
+ private final TenantApiKeyValidator apiKeyValidator;
- public SecurityConfig(JwtUtil jwtUtil) {
+ public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator) {
this.jwtUtil = jwtUtil;
+ this.apiKeyValidator = apiKeyValidator;
}
@Bean
@@ -43,13 +46,17 @@ public class SecurityConfig {
"/api/v1/rn/update/check",
"/api/v1/rn/inspect",
"/api/v1/rn/files/**",
- "/files/apk/**"
+ "/files/apk/**",
+ "/ws/updates/**"
).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))
)
+ // API Key 认证(通用过滤器 + tenant-service 验证)
+ .addFilterBefore(new ApiKeyAuthFilter(apiKeyValidator), UsernamePasswordAuthenticationFilter.class)
+ // JWT 认证
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable);
diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java
new file mode 100644
index 0000000..73d9be9
--- /dev/null
+++ b/update-service/src/main/java/com/xuqm/update/config/TenantApiKeyValidator.java
@@ -0,0 +1,67 @@
+package com.xuqm.update.config;
+
+import com.xuqm.common.security.ApiKeyValidator;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 通过 tenant-service 内部 API 验证 API Key。
+ */
+@Component
+public class TenantApiKeyValidator implements ApiKeyValidator {
+
+ private static final Logger log = LoggerFactory.getLogger(TenantApiKeyValidator.class);
+
+ @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}")
+ private String tenantServiceUrl;
+
+ @Value("${sdk.internal-token:xuqm-internal-token}")
+ private String internalToken;
+
+ private final RestTemplate restTemplate = new RestTemplate();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ /** 缓存:apiKey → appKey,避免每次请求都调用 tenant-service */
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ @Override
+ public String resolveAppKey(String apiKey) {
+ // 先查缓存
+ String cached = cache.get(apiKey);
+ if (cached != null) return cached;
+
+ try {
+ String url = tenantServiceUrl + "/api/internal/sdk/validate-api-key?apiKey=" + apiKey;
+ var headers = new org.springframework.http.HttpHeaders();
+ headers.set("X-Internal-Token", internalToken);
+ var entity = new org.springframework.http.HttpEntity<>(headers);
+
+ var response = restTemplate.exchange(
+ url,
+ org.springframework.http.HttpMethod.GET,
+ entity,
+ String.class
+ );
+
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+ JsonNode root = objectMapper.readTree(response.getBody());
+ JsonNode data = root.path("data");
+ String appKey = data.path("appKey").asText(null);
+ if (appKey != null) {
+ cache.put(apiKey, appKey);
+ }
+ return appKey;
+ }
+ } catch (Exception e) {
+ log.warn("API Key validation failed: {}", e.getMessage());
+ }
+ return null;
+ }
+}
diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java
new file mode 100644
index 0000000..62472ab
--- /dev/null
+++ b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretClient.java
@@ -0,0 +1,61 @@
+package com.xuqm.update.config;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 从 tenant-service 获取 AppSecret,用于回调签名。
+ */
+@Component
+public class TenantAppSecretClient {
+
+ private static final Logger log = LoggerFactory.getLogger(TenantAppSecretClient.class);
+
+ @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}")
+ private String tenantServiceUrl;
+
+ @Value("${sdk.internal-token:xuqm-internal-token}")
+ private String internalToken;
+
+ private final RestTemplate restTemplate = new RestTemplate();
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ /**
+ * 获取指定应用的 AppSecret。
+ * @return AppSecret,获取失败返回 null
+ */
+ public String getAppSecret(String appKey) {
+ String cached = cache.get(appKey);
+ if (cached != null) return cached;
+
+ try {
+ String url = tenantServiceUrl + "/api/internal/sdk/apps/" + appKey + "/secret";
+ var headers = new org.springframework.http.HttpHeaders();
+ headers.set("X-Internal-Token", internalToken);
+ var entity = new org.springframework.http.HttpEntity<>(headers);
+
+ var response = restTemplate.exchange(
+ url, org.springframework.http.HttpMethod.GET, entity, String.class);
+
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+ JsonNode root = objectMapper.readTree(response.getBody());
+ String secret = root.path("data").path("appSecret").asText(null);
+ if (secret != null) {
+ cache.put(appKey, secret);
+ }
+ return secret;
+ }
+ } catch (Exception e) {
+ log.warn("Failed to fetch AppSecret for {}: {}", appKey, e.getMessage());
+ }
+ return null;
+ }
+}
diff --git a/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java b/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java
new file mode 100644
index 0000000..033a7bc
--- /dev/null
+++ b/update-service/src/main/java/com/xuqm/update/config/UpdateWebSocketConfig.java
@@ -0,0 +1,24 @@
+package com.xuqm.update.config;
+
+import com.xuqm.update.handler.UpdateWebSocketHandler;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.socket.config.annotation.EnableWebSocket;
+import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
+import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
+
+@Configuration
+@EnableWebSocket
+public class UpdateWebSocketConfig implements WebSocketConfigurer {
+
+ private final UpdateWebSocketHandler handler;
+
+ public UpdateWebSocketConfig(UpdateWebSocketHandler handler) {
+ this.handler = handler;
+ }
+
+ @Override
+ public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
+ registry.addHandler(handler, "/ws/updates")
+ .setAllowedOrigins("*"); // WebSocket 不走 SecurityConfig 的 CORS
+ }
+}
diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java
index f28c00c..a4c7faa 100644
--- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java
+++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java
@@ -22,6 +22,7 @@ import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.UpdateTenantClient;
+import com.xuqm.update.handler.UpdateWebSocketHandler;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.LicenseFileCrypto;
@@ -38,6 +39,9 @@ public class AppVersionController {
private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient;
private final UpdateTenantClient tenantClient;
+ private final UpdateWebSocketHandler webSocketHandler;
+ private final GrayMemberService grayMemberService;
+ private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService,
@@ -45,7 +49,10 @@ public class AppVersionController {
AppStoreService appStoreService,
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient,
- UpdateTenantClient tenantClient) {
+ UpdateTenantClient tenantClient,
+ UpdateWebSocketHandler webSocketHandler,
+ GrayMemberService grayMemberService,
+ com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
@@ -53,6 +60,9 @@ public class AppVersionController {
this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient;
this.tenantClient = tenantClient;
+ this.webSocketHandler = webSocketHandler;
+ this.grayMemberService = grayMemberService;
+ this.objectMapper = objectMapper;
}
@GetMapping("/app/check")
@@ -99,16 +109,19 @@ public class AppVersionController {
String harmonyJumpUrl = hasText(v.getMarketUrl())
? v.getMarketUrl()
: appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
- return ResponseEntity.ok(ApiResponse.success(Map.of(
- "needsUpdate", true,
- "versionName", v.getVersionName(),
- "versionCode", v.getVersionCode(),
- "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
- "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
- "forceUpdate", forcedHigher.isPresent(),
- "appStoreUrl", appStoreJumpUrl,
- "marketUrl", harmonyJumpUrl
- )));
+ Map response = new java.util.LinkedHashMap<>();
+ response.put("needsUpdate", true);
+ response.put("versionName", v.getVersionName());
+ response.put("versionCode", v.getVersionCode());
+ response.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "");
+ response.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : "");
+ response.put("forceUpdate", forcedHigher.isPresent());
+ response.put("appStoreUrl", appStoreJumpUrl);
+ response.put("marketUrl", harmonyJumpUrl);
+ if (v.getApkHash() != null && !v.getApkHash().isBlank()) {
+ response.put("apkHash", v.getApkHash());
+ }
+ return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/app/upload")
@@ -158,9 +171,15 @@ public class AppVersionController {
entity.setPlatform(platform);
entity.setVersionName(resolvedVersionName);
entity.setVersionCode(resolvedVersionCode);
- entity.setDownloadUrl(platform == AppVersionEntity.Platform.ANDROID
- ? (hasText(apkUrl) ? apkUrl : updateAssetService.storeAppPackage(apkFile))
- : null);
+ if (platform == AppVersionEntity.Platform.ANDROID) {
+ if (hasText(apkUrl)) {
+ entity.setDownloadUrl(apkUrl);
+ } else {
+ UpdateAssetService.StoreResult stored = updateAssetService.storeAppPackage(apkFile);
+ entity.setDownloadUrl(stored != null ? stored.url() : null);
+ entity.setApkHash(stored != null ? stored.hash() : null);
+ }
+ }
entity.setChangeLog(changeLog);
entity.setForceUpdate(forceUpdate);
entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
@@ -259,7 +278,8 @@ public class AppVersionController {
entity.setGrayPercent(0);
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
- entity.setGrayCallbackUrl(null);
+ entity.setGrayGroupNames(null);
+ entity.setExtraMemberIds(null);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
@@ -274,6 +294,10 @@ public class AppVersionController {
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(),
"forceUpdate", saved.isForceUpdate()
));
+ // 发布成功后发送 WebSocket 实时通知
+ if (saved.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) {
+ notifyClientsIfEnabled(saved);
+ }
return ResponseEntity.ok(ApiResponse.success(saved));
}
@@ -360,7 +384,8 @@ public class AppVersionController {
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayPercent(0);
entity.setGrayMemberIds(null);
- entity.setGrayCallbackUrl(null);
+ entity.setGrayGroupNames(null);
+ entity.setExtraMemberIds(null);
} else {
AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode);
@@ -368,35 +393,46 @@ public class AppVersionController {
case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
- entity.setGrayCallbackUrl(null);
+ entity.setGrayGroupNames(null);
+ entity.setExtraMemberIds(null);
}
- case IM_PUSH_USERS -> {
+ case MEMBERS -> {
entity.setGrayPercent(0);
- entity.setGrayMemberIds(null);
- entity.setGrayCallbackUrl(null);
- }
- case CUSTOMER_SYNC -> {
- List memberIds = extractMemberIds(body.get("memberIds"));
- if (memberIds.isEmpty()) {
- memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey());
+ List groupNames = extractMemberIds(body.get("groupNames"));
+ List extraIds = extractMemberIds(body.get("extraMemberIds"));
+
+ // 如果配置了发布时回调,先调用集成方接口同步成员
+ String callbackUrl = publishConfigService.getPublishCallbackUrl(entity.getAppKey());
+ if (callbackUrl != null && !callbackUrl.isBlank()) {
+ log.info("Calling publish callback for {}: {}", entity.getAppKey(), callbackUrl);
+ var syncResult = grayMemberService.callPublishCallback(
+ entity.getAppKey(), callbackUrl,
+ entity.getPlatform().name(), entity.getVersionName(),
+ entity.getVersionCode(), entity.isForceUpdate());
+ if (syncResult != null) {
+ log.info("Publish callback sync result: added={}, updated={}, removed={}",
+ syncResult.added(), syncResult.updated(), syncResult.removed());
+ }
}
- entity.setGrayMemberIds(toJson(memberIds));
- entity.setGrayPercent(0);
- entity.setGrayCallbackUrl(null);
- }
- case CUSTOMER_CALLBACK -> {
- String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null;
- if (callbackUrl == null || callbackUrl.isBlank()) {
- throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode");
- }
- entity.setGrayCallbackUrl(callbackUrl);
- entity.setGrayMemberIds(null);
- entity.setGrayPercent(0);
+
+ entity.setGrayGroupNames(toJson(groupNames));
+ entity.setExtraMemberIds(toJson(extraIds));
+ // 解析标签 + 额外成员 → 最终 userId 列表
+ String resolved = grayMemberService.resolveMemberIds(
+ entity.getAppKey(), groupNames, extraIds);
+ entity.setGrayMemberIds(resolved);
}
}
}
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
AppVersionEntity saved = versionRepository.save(entity);
+ int memberCount = 0;
+ if (saved.getGrayMemberIds() != null) {
+ try {
+ memberCount = objectMapper.readValue(saved.getGrayMemberIds(),
+ new com.fasterxml.jackson.core.type.TypeReference>() {}).size();
+ } catch (Exception ignored) {}
+ }
operationLogService.record(
saved.getAppKey(),
"APP_VERSION",
@@ -407,7 +443,7 @@ public class AppVersionController {
"enabled", enabled,
"grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(),
- "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
+ "memberCount", memberCount
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@@ -426,26 +462,19 @@ public class AppVersionController {
private boolean isInGrayRelease(AppVersionEntity v, String userId) {
return switch (v.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent();
- case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId);
- case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId);
- case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId);
+ case MEMBERS -> {
+ if (v.getGrayMemberIds() == null) yield false;
+ try {
+ List ids = objectMapper.readValue(v.getGrayMemberIds(),
+ new com.fasterxml.jackson.core.type.TypeReference>() {});
+ yield ids.contains(userId);
+ } catch (Exception e) {
+ yield false;
+ }
+ }
};
}
- private boolean resolveCallbackGray(AppVersionEntity v, String userId) {
- if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) {
- return false;
- }
- try {
- List memberIds = publishConfigService.resolveGrayMembersFromUrl(
- v.getGrayCallbackUrl(), v.getAppKey(), userId);
- return memberIds.contains(userId);
- } catch (Exception e) {
- log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage());
- return false;
- }
- }
-
@PatchMapping("/app/{id}/changelog")
public ResponseEntity> updateChangeLog(
@PathVariable String id,
@@ -508,6 +537,18 @@ public class AppVersionController {
return value != null && !value.isBlank();
}
+ /**
+ * 如果发布配置中启用了实时通知,向 WebSocket 客户端推送轻量通知。
+ * 不发送版本详情,由客户端 SDK 收到通知后自动调用 checkUpdate 接口获取最新信息,
+ * 这样可以正确处理灰度发布等业务逻辑。
+ */
+ private void notifyClientsIfEnabled(AppVersionEntity v) {
+ if (!publishConfigService.isRealtimeNotificationEnabled(v.getAppKey())) return;
+ webSocketHandler.notifyVersionPublished(v.getAppKey(), Map.of(
+ "platform", v.getPlatform().name()
+ ));
+ }
+
private List extractMemberIds(Object raw) {
if (raw == null) {
return List.of();
diff --git a/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java b/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java
new file mode 100644
index 0000000..d2eabf6
--- /dev/null
+++ b/update-service/src/main/java/com/xuqm/update/controller/GrayMemberController.java
@@ -0,0 +1,127 @@
+package com.xuqm.update.controller;
+
+import com.xuqm.common.model.ApiResponse;
+import com.xuqm.update.service.GrayMemberService;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 灰度成员和标签管理 API。
+ */
+@RestController
+@RequestMapping("/api/v1/updates/gray")
+public class GrayMemberController {
+
+ private final GrayMemberService grayMemberService;
+
+ public GrayMemberController(GrayMemberService grayMemberService) {
+ this.grayMemberService = grayMemberService;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // 成员管理
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /** 列出所有成员(含标签、来源、活跃状态) */
+ @GetMapping("/members")
+ public ResponseEntity>> listMembers(
+ @RequestParam String appKey) {
+ return ResponseEntity.ok(ApiResponse.success(grayMemberService.listMembers(appKey)));
+ }
+
+ /** 手动添加成员 */
+ @PostMapping("/members")
+ public ResponseEntity> addMembers(@RequestBody Map body) {
+ String appKey = (String) body.get("appKey");
+ @SuppressWarnings("unchecked")
+ List userIds = (List) body.get("userIds");
+ String name = body.get("name") instanceof String s ? s : null;
+ grayMemberService.addMembers(appKey, userIds, name);
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /** 删除成员(连同标签) */
+ @DeleteMapping("/members/{userId}")
+ public ResponseEntity> deleteMember(
+ @RequestParam String appKey, @PathVariable String userId) {
+ grayMemberService.deleteMember(appKey, userId);
+ return ResponseEntity.ok(ApiResponse.success(null));
+ }
+
+ /** 同步成员(从集成方接口获取列表后调用) */
+ @PostMapping("/members/sync")
+ public ResponseEntity> syncMembers(
+ @RequestBody Map body) {
+ String appKey = (String) body.get("appKey");
+ @SuppressWarnings("unchecked")
+ List