From b24e3669cbb2ccb237f36257693c4b8cb34b1a05 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 15 May 2026 16:47:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E5=A4=A7=E6=B3=A2=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demo-service/pom.xml | 5 + .../com/xuqm/demo/config/SecurityConfig.java | 5 - .../demo/controller/DemoAuthController.java | 43 +-- .../demo/controller/DemoUserController.java | 26 +- .../demo/service/DemoAppSecretClient.java | 63 ----- .../xuqm/demo/service/DemoAuthService.java | 125 ++------- .../com/xuqm/demo/service/DemoSdkFactory.java | 40 +++ .../xuqm/demo/service/DemoUserService.java | 47 +++- .../src/main/resources/application.yml | 5 +- .../java/com/xuqm/im/sdk/XuqmImServerSdk.java | 6 +- .../xuqm/tenant/config/SecurityConfig.java | 1 + .../service/ImPlatformEventService.java | 43 +-- .../src/main/resources/application.yml | 1 + .../xuqm/update/entity/AppVersionEntity.java | 2 +- .../xuqm/update/service/AppStoreService.java | 79 ++++++ .../service/StoreSubmissionService.java | 258 ++++++++++++++---- 16 files changed, 444 insertions(+), 305 deletions(-) delete mode 100644 demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java create mode 100644 demo-service/src/main/java/com/xuqm/demo/service/DemoSdkFactory.java diff --git a/demo-service/pom.xml b/demo-service/pom.xml index 533bf79..f7e316b 100644 --- a/demo-service/pom.xml +++ b/demo-service/pom.xml @@ -20,6 +20,11 @@ com.xuqm common + + com.xuqm + im-sdk + 0.1.0-SNAPSHOT + org.springframework.boot spring-boot-starter-web diff --git a/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java b/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java index 6b3d2e7..5658cf2 100644 --- a/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java +++ b/demo-service/src/main/java/com/xuqm/demo/config/SecurityConfig.java @@ -12,7 +12,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.client.RestTemplate; @Configuration @EnableWebSecurity @@ -45,8 +44,4 @@ public class SecurityConfig { return new BCryptPasswordEncoder(); } - @Bean - public RestTemplate restTemplate() { - return new RestTemplate(); - } } diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java index d4c60dc..42b51dd 100644 --- a/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java @@ -2,7 +2,6 @@ package com.xuqm.demo.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.demo.service.DemoAuthService; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -19,9 +18,6 @@ public class DemoAuthController { @PostMapping("/register") public ApiResponse> register(@RequestBody RegisterRequest body) { - if (body.appKey() == null || body.appKey().isBlank()) { - return ApiResponse.badRequest("appKey is required"); - } if (body.userId() == null || body.userId().isBlank()) { return ApiResponse.badRequest("userId is required"); } @@ -29,17 +25,12 @@ public class DemoAuthController { return ApiResponse.badRequest("password must be at least 6 characters"); } - DemoAuthService.AuthResult result = authService.register( - body.appKey(), body.userId(), body.password(), body.nickname()); - + DemoAuthService.AuthResult result = authService.register(body.userId(), body.password(), body.nickname()); return ApiResponse.success(buildResponse(result)); } @PostMapping("/login") public ApiResponse> login(@RequestBody LoginRequest body) { - if (body.appKey() == null || body.appKey().isBlank()) { - return ApiResponse.badRequest("appKey is required"); - } if (body.userId() == null || body.userId().isBlank()) { return ApiResponse.badRequest("userId is required"); } @@ -47,36 +38,32 @@ public class DemoAuthController { return ApiResponse.badRequest("password is required"); } - DemoAuthService.AuthResult result = authService.login( - body.appKey(), body.userId(), body.password()); - + DemoAuthService.AuthResult result = authService.login(body.userId(), body.password()); return ApiResponse.success(buildResponse(result)); } - private Map buildResponse(DemoAuthService.AuthResult result) { - return Map.of( - "demoToken", result.demoToken() != null ? result.demoToken() : "", - "imToken", result.imToken() != null ? result.imToken() : "", - "profile", result.profile() - ); - } - @PostMapping("/reset-password") public ApiResponse resetPassword(@RequestBody ResetPasswordRequest body) { - if (body.appKey() == null || body.appKey().isBlank()) { - return ApiResponse.badRequest("appKey is required"); - } if (body.userId() == null || body.userId().isBlank()) { return ApiResponse.badRequest("userId is required"); } if (body.newPassword() == null || body.newPassword().length() < 6) { return ApiResponse.badRequest("password must be at least 6 characters"); } - authService.resetPassword(body.appKey(), body.userId(), body.newPassword()); + authService.resetPassword(body.userId(), body.newPassword()); return ApiResponse.ok(); } - public record RegisterRequest(String appKey, String userId, String password, String nickname) {} - public record LoginRequest(String appKey, String userId, String password) {} - public record ResetPasswordRequest(String appKey, String userId, String newPassword) {} + private Map buildResponse(DemoAuthService.AuthResult result) { + return Map.of( + "demoToken", result.demoToken() != null ? result.demoToken() : "", + "userSig", result.userSig() != null ? result.userSig() : "", + "userSigExpireAt", result.userSigExpireAt(), + "profile", result.profile() + ); + } + + public record RegisterRequest(String userId, String password, String nickname) {} + public record LoginRequest(String userId, String password) {} + public record ResetPasswordRequest(String userId, String newPassword) {} } diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java index b2e6dc8..b7a4934 100644 --- a/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoUserController.java @@ -19,47 +19,37 @@ public class DemoUserController { } @GetMapping("/user/profile") - public ApiResponse getProfile( - @RequestParam String appKey, - Authentication auth) { - String userId = resolveUserId(auth); - return ApiResponse.success(userService.getProfile(appKey, userId)); + public ApiResponse getProfile(Authentication auth) { + return ApiResponse.success(userService.getProfile(resolveUserId(auth))); } @PutMapping("/user/profile") public ApiResponse updateProfile( - @RequestParam String appKey, Authentication auth, @RequestBody UpdateProfileRequest body) { - String userId = resolveUserId(auth); return ApiResponse.success( - userService.updateProfile(appKey, userId, body.nickname(), body.avatar(), body.gender())); + userService.updateProfile(resolveUserId(auth), body.nickname(), body.avatar(), body.gender())); } @PostMapping("/user/change-password") public ApiResponse changePassword( - @RequestParam String appKey, Authentication auth, @RequestBody ResetPasswordRequest body) { - String userId = resolveUserId(auth); if (body.oldPassword() == null || body.newPassword() == null) { return ApiResponse.badRequest("oldPassword and newPassword are required"); } - userService.resetPassword(appKey, userId, body.oldPassword(), body.newPassword()); + userService.resetPassword(resolveUserId(auth), body.oldPassword(), body.newPassword()); return ApiResponse.ok(); } @GetMapping("/users/search") - public ApiResponse> searchUsers( - @RequestParam String appKey, - @RequestParam String keyword) { - return ApiResponse.success(userService.searchUsers(appKey, keyword)); + public ApiResponse> searchUsers(@RequestParam String keyword) { + return ApiResponse.success(userService.searchUsers(keyword)); } @GetMapping("/users/members") - public ApiResponse> listMembers( - @RequestParam String appKey) { - return ApiResponse.success(userService.listMembers(appKey)); + public ApiResponse> listMembers() { + return ApiResponse.success(userService.listMembers()); } private String resolveUserId(Authentication auth) { diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java deleted file mode 100644 index a80b902..0000000 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAppSecretClient.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.xuqm.demo.service; - -import com.fasterxml.jackson.databind.JsonNode; -import com.xuqm.common.exception.BusinessException; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -public class DemoAppSecretClient { - - private final RestTemplate restTemplate; - private final Map cache = new ConcurrentHashMap<>(); - - @Value("${demo.tenant-service-url:http://127.0.0.1:8081}") - private String tenantServiceUrl; - - @Value("${demo.internal-token:xuqm-internal-token}") - private String internalToken; - - public DemoAppSecretClient(RestTemplate restTemplate) { - this.restTemplate = restTemplate; - } - - public String getAppSecret(String appKey) { - return cache.computeIfAbsent(appKey, this::fetchAppSecret); - } - - private String fetchAppSecret(String appKey) { - String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) - .path("/api/internal/sdk/apps/{appKey}/secret") - .buildAndExpand(appKey) - .toUriString(); - HttpHeaders headers = new HttpHeaders(); - headers.set("X-Internal-Token", internalToken); - try { - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - new HttpEntity<>(headers), - JsonNode.class - ); - JsonNode body = response.getBody(); - if (response.getStatusCode().is2xxSuccessful() - && body != null - && body.path("code").asInt() == 200) { - return body.path("data").path("appSecret").asText(null); - } - } catch (RestClientException e) { - throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage()); - } - throw new BusinessException(502, "Failed to resolve app secret for appKey: " + appKey); - } -} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java index e1bb10c..6374616 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java @@ -1,25 +1,14 @@ package com.xuqm.demo.service; -import com.fasterxml.jackson.databind.JsonNode; import com.xuqm.common.exception.BusinessException; -import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.common.security.JwtUtil; +import com.xuqm.common.security.UserSigUtil; import com.xuqm.demo.entity.DemoUserEntity; import com.xuqm.demo.repository.DemoUserRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; -import java.net.URI; import java.time.Instant; import java.util.Map; @@ -28,43 +17,38 @@ import java.util.UUID; @Service public class DemoAuthService { - private static final Logger log = LoggerFactory.getLogger(DemoAuthService.class); + private static final long USER_SIG_EXPIRE_SECONDS = 180L * 24 * 60 * 60; + + @Value("${demo.app-key}") + private String configuredAppKey; + + @Value("${demo.app-secret}") + private String configuredAppSecret; private final DemoUserRepository userRepository; private final JwtUtil jwtUtil; private final PasswordEncoder passwordEncoder; - private final RestTemplate restTemplate; - private final DemoAppSecretClient appSecretClient; - - @Value("${demo.im-service-url:http://127.0.0.1:8082}") - private String imServiceUrl; public DemoAuthService(DemoUserRepository userRepository, JwtUtil jwtUtil, - PasswordEncoder passwordEncoder, - RestTemplate restTemplate, - DemoAppSecretClient appSecretClient) { + PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.jwtUtil = jwtUtil; this.passwordEncoder = passwordEncoder; - this.restTemplate = restTemplate; - this.appSecretClient = appSecretClient; } - public record AuthResult(String demoToken, String imToken, UserProfile profile) {} - public record ImCredential(String token) {} - + public record AuthResult(String demoToken, String userSig, long userSigExpireAt, UserProfile profile) {} public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {} @Transactional - public AuthResult register(String appKey, String userId, String password, String nickname) { - if (userRepository.existsByAppKeyAndUserId(appKey, userId)) { + public AuthResult register(String userId, String password, String nickname) { + if (userRepository.existsByAppKeyAndUserId(configuredAppKey, userId)) { throw new BusinessException(409, "User already exists: " + userId); } DemoUserEntity user = new DemoUserEntity(); user.setId(UUID.randomUUID().toString()); - user.setAppKey(appKey); + user.setAppKey(configuredAppKey); user.setUserId(userId); user.setPasswordHash(passwordEncoder.encode(password)); user.setNickname(nickname != null ? nickname : userId); @@ -72,91 +56,36 @@ public class DemoAuthService { user.setCreatedAt(Instant.now()); userRepository.save(user); - String demoToken = generateDemoToken(appKey, userId); - ImCredential imCredential = callImServiceLogin(appKey, userId); - - return new AuthResult( - demoToken, - imCredential.token(), - toProfile(user) - ); + String demoToken = generateDemoToken(userId); + String userSig = UserSigUtil.generate(configuredAppSecret, configuredAppKey, userId, USER_SIG_EXPIRE_SECONDS, ""); + long userSigExpireAt = Instant.now().getEpochSecond() + USER_SIG_EXPIRE_SECONDS; + return new AuthResult(demoToken, userSig, userSigExpireAt, toProfile(user)); } @Transactional(readOnly = true) - public AuthResult login(String appKey, String userId, String password) { - DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + public AuthResult login(String userId, String password) { + DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId) .orElseThrow(() -> new BusinessException(401, "Invalid credentials")); - if (!passwordEncoder.matches(password, user.getPasswordHash())) { throw new BusinessException(401, "Invalid credentials"); } - String demoToken = generateDemoToken(appKey, userId); - ImCredential imCredential = callImServiceLogin(appKey, userId); - - return new AuthResult( - demoToken, - imCredential.token(), - toProfile(user) - ); + String demoToken = generateDemoToken(userId); + String userSig = UserSigUtil.generate(configuredAppSecret, configuredAppKey, userId, USER_SIG_EXPIRE_SECONDS, ""); + long userSigExpireAt = Instant.now().getEpochSecond() + USER_SIG_EXPIRE_SECONDS; + return new AuthResult(demoToken, userSig, userSigExpireAt, toProfile(user)); } @Transactional - public void resetPassword(String appKey, String userId, String newPassword) { - DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + public void resetPassword(String userId, String newPassword) { + DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId) .orElseThrow(() -> new BusinessException(404, "User not found: " + userId)); user.setPasswordHash(passwordEncoder.encode(newPassword)); userRepository.save(user); } - private String generateDemoToken(String appKey, String userId) { - return jwtUtil.generate(userId, Map.of("appKey", appKey, "role", "USER")); - } - - /** - * Calls im-service to ensure the IM account exists and obtain an IM token. - */ - private ImCredential callImServiceLogin(String appKey, String userId) { - long timestamp = System.currentTimeMillis(); - String nonce = UUID.randomUUID().toString(); - String appSecret = appSecretClient.getAppSecret(appKey); - String payload = AppRequestSignatureUtil.payload(appKey, userId, timestamp, nonce); - String signature = AppRequestSignatureUtil.sign(appSecret, payload); - - URI uri = UriComponentsBuilder.fromHttpUrl(imServiceUrl) - .path("/api/im/auth/login") - .queryParam("appKey", appKey) - .queryParam("userId", userId) - .encode() - .build() - .toUri(); - - try { - HttpHeaders headers = new HttpHeaders(); - headers.set("X-App-Timestamp", String.valueOf(timestamp)); - headers.set("X-App-Nonce", nonce); - headers.set("X-App-Signature", signature); - ResponseEntity response = restTemplate.exchange( - uri, - HttpMethod.POST, - new HttpEntity<>(headers), - JsonNode.class - ); - JsonNode body = response.getBody(); - if (body != null && body.path("code").asInt() == 200) { - JsonNode data = body.path("data"); - String token = data.path("token").asText(null); - if (token == null || token.isBlank()) { - throw new BusinessException(502, "Failed to acquire IM token"); - } - return new ImCredential(token); - } - log.warn("im-service login returned unexpected response for appKey={} userId={}: {}", appKey, userId, body); - throw new BusinessException(502, "Failed to acquire IM token"); - } catch (RestClientException e) { - log.error("Failed to call im-service login for appKey={} userId={}: {}", appKey, userId, e.getMessage()); - throw new BusinessException(502, "Failed to acquire IM token"); - } + private String generateDemoToken(String userId) { + return jwtUtil.generate(userId, Map.of("appKey", configuredAppKey, "role", "USER")); } private UserProfile toProfile(DemoUserEntity user) { diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoSdkFactory.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoSdkFactory.java new file mode 100644 index 0000000..d417fe6 --- /dev/null +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoSdkFactory.java @@ -0,0 +1,40 @@ +package com.xuqm.demo.service; + +import com.xuqm.im.sdk.XuqmImServerSdk; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class DemoSdkFactory { + + @Value("${demo.app-key}") + private String appKey; + + @Value("${demo.app-secret}") + private String appSecret; + + @Value("${demo.im-service-url:http://127.0.0.1:8082}") + private String imServiceUrl; + + @Value("${demo.push-service-url:http://127.0.0.1:8083}") + private String pushServiceUrl; + + public XuqmImServerSdk sdk() { + return XuqmImServerSdk.builder() + .baseUrl(imServiceUrl) + .pushBaseUrl(pushServiceUrl) + .appKey(appKey) + .appSecret(appSecret) + .build(); + } + + public XuqmImServerSdk sdk(String bearerToken) { + return XuqmImServerSdk.builder() + .baseUrl(imServiceUrl) + .pushBaseUrl(pushServiceUrl) + .appKey(appKey) + .appSecret(appSecret) + .bearerTokenSupplier(() -> bearerToken) + .build(); + } +} diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java index a6b5732..8a14b55 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoUserService.java @@ -3,6 +3,9 @@ package com.xuqm.demo.service; import com.xuqm.common.exception.BusinessException; import com.xuqm.demo.entity.DemoUserEntity; import com.xuqm.demo.repository.DemoUserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,26 +15,34 @@ import java.util.List; @Service public class DemoUserService { + private static final Logger log = LoggerFactory.getLogger(DemoUserService.class); + + @Value("${demo.app-key}") + private String configuredAppKey; + private final DemoUserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final DemoSdkFactory sdkFactory; - public DemoUserService(DemoUserRepository userRepository, PasswordEncoder passwordEncoder) { + public DemoUserService(DemoUserRepository userRepository, PasswordEncoder passwordEncoder, + DemoSdkFactory sdkFactory) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.sdkFactory = sdkFactory; } public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {} @Transactional(readOnly = true) - public UserProfile getProfile(String appKey, String userId) { - DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + public UserProfile getProfile(String userId) { + DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId) .orElseThrow(() -> new BusinessException(404, "User not found")); return toProfile(user); } @Transactional - public UserProfile updateProfile(String appKey, String userId, String nickname, String avatar, String gender) { - DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + public UserProfile updateProfile(String userId, String nickname, String avatar, String gender) { + DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId) .orElseThrow(() -> new BusinessException(404, "User not found")); if (nickname != null && !nickname.isBlank()) { @@ -47,14 +58,17 @@ public class DemoUserService { throw new BusinessException(400, "Invalid gender value: " + gender); } } - userRepository.save(user); + + syncImProfile(userId, user.getNickname(), user.getAvatar(), + user.getGender() != null ? user.getGender().name() : null); + return toProfile(user); } @Transactional - public void resetPassword(String appKey, String userId, String oldPassword, String newPassword) { - DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + public void resetPassword(String userId, String oldPassword, String newPassword) { + DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId) .orElseThrow(() -> new BusinessException(404, "User not found")); if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) { @@ -69,24 +83,33 @@ public class DemoUserService { } @Transactional(readOnly = true) - public List searchUsers(String appKey, String keyword) { + public List searchUsers(String keyword) { if (keyword == null || keyword.isBlank()) { throw new BusinessException(400, "Search keyword must not be blank"); } - return userRepository.searchByKeyword(appKey, keyword.trim()) + return userRepository.searchByKeyword(configuredAppKey, keyword.trim()) .stream() .map(this::toProfile) .toList(); } @Transactional(readOnly = true) - public List listMembers(String appKey) { - return userRepository.findAllByAppKeyOrderByCreatedAtAsc(appKey) + public List listMembers() { + return userRepository.findAllByAppKeyOrderByCreatedAtAsc(configuredAppKey) .stream() .map(this::toProfile) .toList(); } + private void syncImProfile(String userId, String nickname, String avatar, String gender) { + try { + String imToken = sdkFactory.sdk().login(userId).token(); + sdkFactory.sdk(imToken).updateProfile(userId, nickname, avatar, gender); + } catch (Exception e) { + log.warn("Failed to sync IM profile for userId={}: {}", userId, e.getMessage()); + } + } + private UserProfile toProfile(DemoUserEntity user) { return new UserProfile( user.getAppKey(), diff --git a/demo-service/src/main/resources/application.yml b/demo-service/src/main/resources/application.yml index 61095cc..d022971 100644 --- a/demo-service/src/main/resources/application.yml +++ b/demo-service/src/main/resources/application.yml @@ -35,9 +35,10 @@ jwt: expiration: 3153600000000 demo: - tenant-service-url: ${TENANT_SERVICE_URL:http://127.0.0.1:9001} - internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} + app-key: ${DEMO_APP_KEY} + app-secret: ${DEMO_APP_SECRET} im-service-url: ${IM_SERVICE_URL:http://127.0.0.1:8082} + push-service-url: ${PUSH_SERVICE_URL:http://127.0.0.1:8083} logging: level: diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index abe08d9..64448e2 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -1584,8 +1584,9 @@ public final class XuqmImServerSdk { ) { try { HttpRequest.Builder builder = HttpRequest.newBuilder(uri) - .header("Content-Type", "application/json") - .headers(flatten(headers)); + .header("Content-Type", "application/json"); + String[] flatHeaders = flatten(headers); + if (flatHeaders.length > 0) builder = builder.headers(flatHeaders); if (body != null) { builder.method(method, HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))); } else if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) { @@ -1843,6 +1844,7 @@ public final class XuqmImServerSdk { } private String[] flatten(Map headers) { + if (headers == null || headers.isEmpty()) return new String[0]; String[] pairs = new String[headers.size() * 2]; int index = 0; for (Map.Entry entry : headers.entrySet()) { diff --git a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java index 466d1a9..7953c38 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/config/SecurityConfig.java @@ -35,6 +35,7 @@ public class SecurityConfig { "/api/auth/**", "/api/sdk/**", "/api/internal/sdk/**", + "/api/internal/im/**", "/actuator/health", "/actuator/info" ).permitAll() diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java index e8ce1ea..a41d0cf 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java @@ -30,6 +30,9 @@ public class ImPlatformEventService { @Value("${sdk.im-platform-events-admin-user:admin}") private String platformEventsAdminUser; + @Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}") + private String imPlatformAppKey; + public ImPlatformEventService(SdkAppProvisioningService provisioningService, ObjectMapper objectMapper) { this.provisioningService = provisioningService; @@ -37,24 +40,24 @@ public class ImPlatformEventService { } public Map issueToken(String appKey) throws Exception { - AppEntity app = provisioningService.resolveApp(appKey); + AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey); String userId = platformEventsRecipientUserId(); - log.info("IM platform event token login start appKey={} userId={}", app.getAppKey(), userId); - String token = requestImToken(app, userId); + log.info("IM platform event token login start platformAppKey={} userId={}", platformApp.getAppKey(), userId); + String token = requestImToken(platformApp, userId); Map result = new LinkedHashMap<>(); - result.put("appKey", app.getAppKey()); + result.put("appKey", platformApp.getAppKey()); result.put("userId", userId); result.put("token", token); - log.info("IM platform event token issued appKey={} userId={}", app.getAppKey(), userId); + log.info("IM platform event token issued platformAppKey={} userId={}", platformApp.getAppKey(), userId); return result; } public Map notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception { - AppEntity app = provisioningService.resolveApp(request.appKey()); + AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey); String recipientUserId = platformEventsRecipientUserId(); String senderUserId = platformEventsAdminUserId(); - String senderToken = requestImToken(app, senderUserId); - XuqmImServerSdk sdk = sdk(app, senderToken); + String senderToken = requestImToken(platformApp, senderUserId); + XuqmImServerSdk sdk = sdk(platformApp, senderToken); Map contentPayload = new LinkedHashMap<>(); contentPayload.put("event", request.event() == null || request.event().isBlank() ? "store_review_update" : request.event()); @@ -70,8 +73,8 @@ public class ImPlatformEventService { contentPayload.put("timestamp", System.currentTimeMillis()); String content = objectMapper.writeValueAsString(contentPayload); - log.info("IM platform event send appKey={} recipient={} sender={} event={} storeType={} state={} stage={} batchId={}", - app.getAppKey(), recipientUserId, senderUserId, + log.info("IM platform event send platformAppKey={} recipient={} sender={} event={} storeType={} state={} stage={} batchId={}", + platformApp.getAppKey(), recipientUserId, senderUserId, request.event() == null || request.event().isBlank() ? "store_review_update" : request.event(), request.storeType(), request.reviewState(), request.stage(), request.batchId()); var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest( @@ -82,32 +85,32 @@ public class ImPlatformEventService { content, null )); - log.info("IM platform event message sent appKey={} recipient={} messageId={}", - app.getAppKey(), recipientUserId, message.id()); + log.info("IM platform event message sent platformAppKey={} recipient={} messageId={}", + platformApp.getAppKey(), recipientUserId, message.id()); Map result = new LinkedHashMap<>(); - result.put("appKey", app.getAppKey()); + result.put("appKey", platformApp.getAppKey()); result.put("userId", recipientUserId); result.put("messageId", message.id()); return result; } - private XuqmImServerSdk sdk(AppEntity app, String bearerToken) { + private XuqmImServerSdk sdk(AppEntity platformApp, String bearerToken) { return XuqmImServerSdk.builder() .baseUrl(imApiUrl) - .appKey(app.getAppKey()) - .appSecret(app.getAppSecret()) + .appKey(platformApp.getAppKey()) + .appSecret(platformApp.getAppSecret()) .bearerTokenSupplier(() -> bearerToken) .build(); } - private String requestImToken(AppEntity app, String userId) throws Exception { + private String requestImToken(AppEntity platformApp, String userId) throws Exception { XuqmImServerSdk sdk = XuqmImServerSdk.builder() .baseUrl(imApiUrl) - .appKey(app.getAppKey()) - .appSecret(app.getAppSecret()) + .appKey(platformApp.getAppKey()) + .appSecret(platformApp.getAppSecret()) .build(); - String userSig = UserSigUtil.generate(app.getAppSecret(), app.getAppKey(), userId); + String userSig = UserSigUtil.generate(platformApp.getAppSecret(), platformApp.getAppKey(), userId); String token = sdk.loginWithUserSig(userId, userSig).token(); if (token == null || token.isBlank()) { throw new IllegalStateException("Failed to issue IM token: empty token"); diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index 78c7e4d..dc60a7c 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -90,3 +90,4 @@ sdk: im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com} im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform} im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin} + im-platform-app-key: ${SDK_IM_PLATFORM_APP_KEY:ak_409e217e4aa14254ad73ad3c} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index f312a6e..d5a7082 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -15,7 +15,7 @@ public class AppVersionEntity { public enum Platform { ANDROID, IOS, HARMONY } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } /** Per-store review state used in storeReviewStatus JSON values. */ - public enum StoreReviewState { PENDING, SUBMITTING, UNDER_REVIEW, APPROVED, REJECTED } + public enum StoreReviewState { PENDING, SUBMITTING, UNDER_REVIEW, APPROVED, REJECTED, WITHDRAWN } /** * Gray release mode. * PERCENT: deterministic hash-based percentage of all users. diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index c84b6f5..8b5da29 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -28,6 +28,7 @@ public class AppStoreService { private static final Logger log = LoggerFactory.getLogger(AppStoreService.class); private static final ObjectMapper mapper = new ObjectMapper(); + private static final Set ACTIVE_REVIEW_STATES = Set.of("PENDING", "SUBMITTING", "UNDER_REVIEW"); private final HttpClient http = HttpClient.newHttpClient(); private final AppStoreConfigRepository configRepo; @@ -124,6 +125,11 @@ public class AppStoreService { throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED"); } + // Reject submission of a lower version when a higher one has active reviews + checkNoHigherVersionInReview(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets); + // Withdraw lower versions' active reviews for the same stores + cancelSupersededVersionReviews(v.getAppKey(), v.getPlatform(), versionId, v.getVersionCode(), resolvedTargets); + Map reviewMap = new LinkedHashMap<>(); for (String store : resolvedTargets) { reviewMap.put(store, reviewPayload( @@ -512,6 +518,7 @@ public class AppStoreService { case REJECTED -> "FAILED"; case SUBMITTING -> "SUBMITTING"; case PENDING -> "QUEUED"; + case WITHDRAWN -> "WITHDRAWN"; }; } @@ -529,6 +536,78 @@ public class AppStoreService { return value.toString(); } + /** + * When version V is submitted, mark lower versions' active reviews for the same stores as WITHDRAWN. + * A higher version supersedes all lower versions in the same app+platform+store. + */ + private void cancelSupersededVersionReviews(String appKey, + AppVersionEntity.Platform platform, + String currentVersionId, + int currentVersionCode, + List storeTypes) throws Exception { + Set storeSet = new HashSet<>(storeTypes); + List others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + for (AppVersionEntity other : others) { + if (other.getId().equals(currentVersionId)) continue; + if (other.getVersionCode() >= currentVersionCode) continue; + if (other.getStoreReviewStatus() == null || other.getStoreReviewStatus().isBlank()) continue; + + Map reviewMap = parseReviewStatus(other.getStoreReviewStatus()); + boolean changed = false; + for (String storeType : storeSet) { + Object entry = reviewMap.get(storeType); + if (entry == null) continue; + if (!ACTIVE_REVIEW_STATES.contains(readReviewState(entry))) continue; + + Map cur = asReviewPayload(entry); + reviewMap.put(storeType, reviewPayload( + AppVersionEntity.StoreReviewState.WITHDRAWN.name(), + "已被更高版本 " + currentVersionCode + " 取代", + "WITHDRAWN", + readText(cur.get("batchId")).isBlank() ? null : readText(cur.get("batchId")), + readText(cur.get("submittedAt")).isBlank() ? null : readText(cur.get("submittedAt")), + LocalDateTime.now().toString())); + changed = true; + } + if (changed) { + other.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + versionRepo.save(other); + log.info("Withdrew {} store review(s) on version {} (versionCode={}) superseded by version {} (versionCode={})", + storeSet, other.getId(), other.getVersionCode(), currentVersionId, currentVersionCode); + } + } + } + + /** + * Reject submission of a lower version if any higher version already has an active review + * for the same store. Prevents conflicting submissions. + */ + private void checkNoHigherVersionInReview(String appKey, + AppVersionEntity.Platform platform, + String currentVersionId, + int currentVersionCode, + List storeTypes) throws Exception { + Set storeSet = new HashSet<>(storeTypes); + List others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + for (AppVersionEntity other : others) { + if (other.getId().equals(currentVersionId)) continue; + if (other.getVersionCode() <= currentVersionCode) continue; + if (other.getStoreReviewStatus() == null || other.getStoreReviewStatus().isBlank()) continue; + + Map reviewMap = parseReviewStatus(other.getStoreReviewStatus()); + for (String storeType : storeSet) { + Object entry = reviewMap.get(storeType); + if (entry == null) continue; + String state = readReviewState(entry); + if (ACTIVE_REVIEW_STATES.contains(state)) { + throw new IllegalStateException( + "无法提交: 更高版本 " + other.getVersionName() + " 的 " + storeType + + " 审核正在进行中 (状态: " + state + "),请等待审核完成后再提交低版本"); + } + } + } + } + private Object lockFor(String versionId) { return versionLocks.computeIfAbsent(versionId, ignored -> new Object()); } diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index 05bc113..c8da571 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -210,16 +210,20 @@ public class StoreSubmissionService { rejectedCount.incrementAndGet(); String message = describeException(e); log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), e); - recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( - "durationMs", System.currentTimeMillis() - plan.storeStartedAt, - "phase", "SUBMISSION", - "errorClass", e.getClass().getName(), - "reason", message - ), message); + try { + recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of( + "durationMs", System.currentTimeMillis() - plan.storeStartedAt, + "phase", "SUBMISSION", + "errorClass", e.getClass().getName(), + "reason", message + ), message); + } catch (Exception logEx) { + log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage()); + } try { storeService.updateStoreReview(versionId, plan.storeType, AppVersionEntity.StoreReviewState.REJECTED, - message); + message.length() > 500 ? message.substring(0, 500) : message); } catch (Exception ex) { log.warn("Failed to persist rejection for {}/{} batchId={}: {}", v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex); @@ -230,9 +234,9 @@ public class StoreSubmissionService { if (!futures.isEmpty()) { try { CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) - .get(20, java.util.concurrent.TimeUnit.MINUTES); + .get(120, java.util.concurrent.TimeUnit.MINUTES); } catch (java.util.concurrent.TimeoutException te) { - log.error("Store submit batch timed out after 20 minutes for version={}", versionId); + log.error("Store submit batch timed out after 120 minutes for version={}", versionId); futures.forEach(f -> f.cancel(true)); } catch (Exception e) { log.error("Store submit batch wait error for version={}: {}", versionId, e.getMessage()); @@ -377,33 +381,57 @@ public class StoreSubmissionService { return requireBodyMap(resp.getBody(), "Huawei upload url"); } - private void huaweiUploadFile(String uploadUrl, Map extraHeaders, File file) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); - if (extraHeaders != null) extraHeaders.forEach(headers::set); - FileSystemResource resource = new FileSystemResource(file); - rest.exchange(uploadUrl, HttpMethod.PUT, new HttpEntity<>(resource, headers), Void.class); + private static final Set RESTRICTED_HTTP_HEADERS = Set.of( + "host", "content-length", "connection", "transfer-encoding", "upgrade", "expect", "te", "trailer"); + + private void huaweiUploadFile(String uploadUrl, Map extraHeaders, File file) throws Exception { + java.net.http.HttpRequest.Builder builder = java.net.http.HttpRequest.newBuilder() + .uri(URI.create(uploadUrl)) + .timeout(Duration.ofMinutes(120)) + .PUT(java.net.http.HttpRequest.BodyPublishers.ofFile(file.toPath())); + // Apply presigned headers first (they include Content-Type, Authorization, x-amz-* etc.) + if (extraHeaders != null) { + extraHeaders.forEach((k, v) -> { + if (!RESTRICTED_HTTP_HEADERS.contains(k.toLowerCase(java.util.Locale.ROOT))) { + builder.header(k, v); + } + }); + } + // Fall back to Content-Type only if not already provided by presigned headers + boolean hasContentType = extraHeaders != null && extraHeaders.keySet().stream() + .anyMatch(k -> k.equalsIgnoreCase("Content-Type")); + if (!hasContentType) { + builder.header("Content-Type", "application/octet-stream"); + } + java.net.http.HttpResponse resp = uploadHttpClient.send( + builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() >= 400) { + throw new RuntimeException("Huawei file upload failed: HTTP " + resp.statusCode() + " " + resp.body()); + } } @SuppressWarnings("unchecked") private String huaweiBindApk(String clientId, String token, String hwAppId, String fileName, String objectId) { + // Huawei requires fileName 1-64 chars; cached file names can exceed this + String safeFileName = fileName.length() > 64 + ? fileName.substring(0, 59) + ".apk" + : fileName; HttpHeaders headers = huaweiHeaders(clientId, token); headers.setContentType(MediaType.APPLICATION_JSON); - Map body = Map.of("files", List.of(Map.of("fileName", fileName, "fileDestUrl", objectId))); + Map body = Map.of( + "fileType", 5, + "files", List.of(Map.of("fileName", safeFileName, "fileDestUrl", objectId))); ResponseEntity resp = rest.exchange( HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId, HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class); Map respBody = requireBodyMap(resp.getBody(), "Huawei bind apk"); - List> pkgList = asMapList(respBody.get("pkgVersion")); - if (pkgList.isEmpty()) { - pkgList = asMapList(respBody.get("data")); - } - if (pkgList.isEmpty()) { - throw new RuntimeException("Huawei: bind apk response missing pkgVersion, response=" + summarizeMap(respBody)); - } - String pkgId = firstText(pkgList.get(0), "id", "pkgId", "packageId"); + // pkgVersion may be a list of maps {"id":...} or a plain string list [""] + String pkgId = extractHuaweiPkgId(respBody, "pkgVersion"); if (pkgId.isBlank()) { - throw new RuntimeException("Huawei: bind apk response missing pkg id, response=" + summarizeMap(pkgList.get(0))); + pkgId = extractHuaweiPkgId(respBody, "data"); + } + if (pkgId.isBlank()) { + throw new RuntimeException("Huawei: bind apk response missing pkgVersion, response=" + summarizeMap(respBody)); } return pkgId; } @@ -473,14 +501,20 @@ public class StoreSubmissionService { Map uploadInfo = honorGetUploadUrl(token, honorAppId, file, fileSha256); long objectId = ((Number) uploadInfo.get("objectId")).longValue(); - // 4. Upload file via multipart - honorUploadFile(token, honorAppId, objectId, file); + // 4. Upload file via multipart — get fresh token immediately before upload so the + // 1-hour validity window covers the entire ~60-min large-file transfer. + // curl is used (vs Java HttpClient) so Expect:100-continue causes HONOR to validate + // the token during header exchange (before receiving the body), not after. + String uploadToken = honorGetToken(clientId, clientSecret); + honorUploadFile(uploadToken, honorAppId, objectId, file); - // 5. Bind APK file info - honorUpdateFileInfo(token, honorAppId, objectId); + // 5. Bind APK file info — get fresh token in case upload exhausted the previous one + String bindToken = honorGetToken(clientId, clientSecret); + honorUpdateFileInfo(bindToken, honorAppId, objectId); // 6. Submit for review - honorSubmit(token, honorAppId, v.getChangeLog()); + String submitToken = honorGetToken(clientId, clientSecret); + honorSubmit(submitToken, honorAppId, v.getChangeLog()); } private String honorGetToken(String clientId, String clientSecret) { @@ -530,15 +564,31 @@ public class StoreSubmissionService { return list.get(0); } - private void honorUploadFile(String token, int appId, long objectId, File file) { - HttpHeaders headers = honorHeaders(token); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", new FileSystemResource(file)); - ResponseEntity resp = rest.postForEntity( - HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId, - new HttpEntity<>(body, headers), Map.class); - assertHonorSuccess(resp.getBody(), "file-upload"); + @SuppressWarnings("unchecked") + private void honorUploadFile(String token, int appId, long objectId, File file) throws Exception { + String url = HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId; + // curl sends Expect:100-continue so HONOR validates the token during header negotiation + // (before the 430MB body arrives), avoiding token-expiry after a long transfer. + ProcessBuilder pb = new ProcessBuilder( + "curl", "-s", "--connect-timeout", "30", + "--max-time", String.valueOf(130 * 60), + "-H", "Authorization: Bearer " + token, + "-F", "file=@" + file.getAbsolutePath() + ";type=application/octet-stream", + url + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + String responseBody = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + boolean completed = process.waitFor(130, java.util.concurrent.TimeUnit.MINUTES); + if (!completed) { + process.destroyForcibly(); + throw new IllegalStateException("curl upload to HONOR timed out"); + } + if (responseBody.isEmpty()) { + throw new IllegalStateException("curl upload to HONOR returned empty response (exit=" + process.exitValue() + ")"); + } + Map body = mapper.readValue(responseBody, new TypeReference<>() {}); + assertHonorSuccess(body, "file-upload"); } private void honorUpdateFileInfo(String token, int appId, long objectId) { @@ -793,15 +843,14 @@ public class StoreSubmissionService { File file, String clientSecret) throws Exception { Map params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", "")); - String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, false, clientSecret); - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", new FileSystemResource(file)); - body.add("type", "apk"); - body.add("sign", uploadUrl.getOrDefault("sign", "")); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - ResponseEntity response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class); - JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + // paramsAppendQuery=true: put type+sign in URL so CDN can verify api_sign from URL params alone + String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, true, clientSecret); + Map extraFields = new LinkedHashMap<>(); + extraFields.put("type", "apk"); + extraFields.put("sign", uploadUrl.getOrDefault("sign", "")); + java.net.http.HttpResponse response = multipartUploadWithTimeout( + requestUrl, "file", file, extraFields, null); + JsonNode root = mapper.readTree(response.body()); oppoCheckSuccess(root, "上传Apk"); return root.path("data"); } @@ -906,12 +955,26 @@ public class StoreSubmissionService { "fileMd5", md5Hex(file) ); String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.upload.apk.app", params); - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("file", new FileSystemResource(file)); - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.MULTIPART_FORM_DATA); - ResponseEntity response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class); - JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody())); + // Use curl via ProcessBuilder: curl sends Expect:100-continue for large files, + // bypassing VIVO API gateway's ~3.5min request body timeout on direct streaming. + ProcessBuilder pb = new ProcessBuilder( + "curl", "-s", "--connect-timeout", "30", + "--max-time", String.valueOf(130 * 60), + "-F", "file=@" + file.getAbsolutePath() + ";type=application/octet-stream", + requestUrl + ); + pb.redirectErrorStream(true); + Process process = pb.start(); + String responseBody = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).trim(); + boolean completed = process.waitFor(130, java.util.concurrent.TimeUnit.MINUTES); + if (!completed) { + process.destroyForcibly(); + throw new IllegalStateException("curl upload to VIVO timed out"); + } + if (responseBody.isEmpty()) { + throw new IllegalStateException("curl upload to VIVO returned empty response (exit=" + process.exitValue() + ")"); + } + JsonNode root = mapper.readTree(responseBody); vivoCheckSuccess(root, "上传apk"); return root.path("data"); } @@ -969,10 +1032,78 @@ public class StoreSubmissionService { org.springframework.http.client.SimpleClientHttpRequestFactory factory = new org.springframework.http.client.SimpleClientHttpRequestFactory(); factory.setConnectTimeout(30_000); - factory.setReadTimeout(300_000); // 5 minutes for large APK uploads + factory.setReadTimeout(300_000); return new RestTemplate(factory); } + private static final java.net.http.HttpClient uploadHttpClient = java.net.http.HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .version(java.net.http.HttpClient.Version.HTTP_1_1) + .build(); + + private java.net.http.HttpResponse multipartUploadWithTimeout( + String url, + String fileFieldName, + File file, + Map extraFields, + Map extraHeaders) throws Exception { + String boundary = "----FormBoundary" + UUID.randomUUID().toString().replace("-", ""); + + // Build only the small preamble and epilogue in memory; stream the file itself + ByteArrayOutputStream preamble = new ByteArrayOutputStream(); + if (extraFields != null) { + for (Map.Entry e : extraFields.entrySet()) { + preamble.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + preamble.write(("Content-Disposition: form-data; name=\"" + e.getKey() + "\"\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + preamble.write((e.getValue() + "\r\n").getBytes(StandardCharsets.UTF_8)); + } + } + preamble.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8)); + preamble.write(("Content-Disposition: form-data; name=\"" + fileFieldName + + "\"; filename=\"" + file.getName() + "\"\r\n").getBytes(StandardCharsets.UTF_8)); + preamble.write("Content-Type: application/octet-stream\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + byte[] preambleBytes = preamble.toByteArray(); + byte[] epilogueBytes = ("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8); + long contentLength = preambleBytes.length + file.length() + epilogueBytes.length; + + java.net.http.HttpRequest.BodyPublisher bodyPublisher = java.net.http.HttpRequest.BodyPublishers.fromPublisher( + java.net.http.HttpRequest.BodyPublishers.ofInputStream(() -> { + try { + return new java.io.SequenceInputStream( + new java.util.Enumeration() { + private final java.io.InputStream[] streams = { + new java.io.ByteArrayInputStream(preambleBytes), + new FileInputStream(file), + new java.io.ByteArrayInputStream(epilogueBytes) + }; + private int idx = 0; + public boolean hasMoreElements() { return idx < streams.length; } + public java.io.InputStream nextElement() { return streams[idx++]; } + } + ); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + }), + contentLength + ); + + java.net.http.HttpRequest.Builder builder = java.net.http.HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofMinutes(120)) + .POST(bodyPublisher) + .header("Content-Type", "multipart/form-data; boundary=" + boundary); + if (extraHeaders != null) { + extraHeaders.forEach((k, v) -> { + if (!RESTRICTED_HTTP_HEADERS.contains(k.toLowerCase(java.util.Locale.ROOT))) { + builder.header(k, v); + } + }); + } + return uploadHttpClient.send(builder.build(), java.net.http.HttpResponse.BodyHandlers.ofString()); + } + private String vivoEncodeValue(String value) { if (value == null) return ""; try { @@ -1123,7 +1254,21 @@ public class StoreSubmissionService { return (Map) body; } + /** Extracts the HUAWEI pkgId from a bind-apk response field that may be + * either a list of maps [{id: "..."}, ...] or a plain string list [""]. */ @SuppressWarnings("unchecked") + private String extractHuaweiPkgId(Map respBody, String key) { + Object raw = respBody.get(key); + if (!(raw instanceof List list) || list.isEmpty()) return ""; + Object first = list.get(0); + if (first instanceof Map map) { + return firstText((Map) map, "id", "pkgId", "packageId", "pkgVersion"); + } + // plain string element — the value itself is the ID + String s = String.valueOf(first).trim(); + return s.isBlank() ? "" : s; + } + private List> asMapList(Object value) { if (!(value instanceof List list)) { return List.of(); @@ -1248,6 +1393,7 @@ public class StoreSubmissionService { if (message == null || message.isBlank()) { message = root.getClass().getSimpleName(); } - return root.getClass().getSimpleName() + ": " + message; + String full = root.getClass().getSimpleName() + ": " + message; + return full.length() > 900 ? full.substring(0, 900) + "..." : full; } }