feat(update): 添加 API Key 管理和 WebSocket 实时通知功能
- 新增 API Key 管理功能,支持外部工具认证调用平台 API - 实现 WebSocket 实时通知,版本发布时推送轻量通知给客户端 - 添加 APK 文件哈希校验,支持已下载检测和直接安装 - 支持外部 APK 上传使用 API Key 认证 - 优化私有化部署自动注入 nginx WebSocket 代理配置 - 扩展 SDK 功能包括已下载检测、直接安装和实时通知监听
这个提交包含在:
父节点
e3d7fbd591
当前提交
3e2db6441e
@ -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"));
|
||||||
|
|
||||||
|
// 如果配置了发布时回调,先调用集成方接口同步成员
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
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.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,25 +462,18 @@ 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean resolveCallbackGray(AppVersionEntity v, String userId) {
|
|
||||||
if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
List<String> memberIds = publishConfigService.resolveGrayMembersFromUrl(
|
List<String> ids = objectMapper.readValue(v.getGrayMemberIds(),
|
||||||
v.getGrayCallbackUrl(), v.getAppKey(), userId);
|
new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
return memberIds.contains(userId);
|
yield ids.contains(userId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage());
|
yield false;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@PatchMapping("/app/{id}/changelog")
|
@PatchMapping("/app/{id}/changelog")
|
||||||
public ResponseEntity<ApiResponse<AppVersionEntity>> updateChangeLog(
|
public ResponseEntity<ApiResponse<AppVersionEntity>> updateChangeLog(
|
||||||
@ -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,25 +313,18 @@ 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean resolveRnCallbackGray(RnBundleEntity b, String userId) {
|
|
||||||
if (b.getGrayCallbackUrl() == null || b.getGrayCallbackUrl().isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
List<String> memberIds = publishConfigService.resolveGrayMembersFromUrl(
|
List<String> ids = objectMapper.readValue(b.getGrayMemberIds(),
|
||||||
b.getGrayCallbackUrl(), b.getAppKey(), userId);
|
new com.fasterxml.jackson.core.type.TypeReference<List<String>>() {});
|
||||||
return memberIds.contains(userId);
|
yield ids.contains(userId);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("RN gray callback failed for appKey={} bundleId={}: {}", b.getAppKey(), b.getId(), e.getMessage());
|
yield false;
|
||||||
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;
|
||||||
|
|||||||
@ -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.
|
|
||||||
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());
|
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 {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户