一大波改动
这个提交包含在:
父节点
fb8a9d453d
当前提交
b24e3669cb
@ -20,6 +20,11 @@
|
||||
<groupId>com.xuqm</groupId>
|
||||
<artifactId>common</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xuqm</groupId>
|
||||
<artifactId>im-sdk</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Void> 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<String, Object> 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) {}
|
||||
}
|
||||
|
||||
@ -19,47 +19,37 @@ public class DemoUserController {
|
||||
}
|
||||
|
||||
@GetMapping("/user/profile")
|
||||
public ApiResponse<DemoUserService.UserProfile> getProfile(
|
||||
@RequestParam String appKey,
|
||||
Authentication auth) {
|
||||
String userId = resolveUserId(auth);
|
||||
return ApiResponse.success(userService.getProfile(appKey, userId));
|
||||
public ApiResponse<DemoUserService.UserProfile> getProfile(Authentication auth) {
|
||||
return ApiResponse.success(userService.getProfile(resolveUserId(auth)));
|
||||
}
|
||||
|
||||
@PutMapping("/user/profile")
|
||||
public ApiResponse<DemoUserService.UserProfile> 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<Void> 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<List<DemoUserService.UserProfile>> searchUsers(
|
||||
@RequestParam String appKey,
|
||||
@RequestParam String keyword) {
|
||||
return ApiResponse.success(userService.searchUsers(appKey, keyword));
|
||||
public ApiResponse<List<DemoUserService.UserProfile>> searchUsers(@RequestParam String keyword) {
|
||||
return ApiResponse.success(userService.searchUsers(keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/users/members")
|
||||
public ApiResponse<List<DemoUserService.UserProfile>> listMembers(
|
||||
@RequestParam String appKey) {
|
||||
return ApiResponse.success(userService.listMembers(appKey));
|
||||
public ApiResponse<List<DemoUserService.UserProfile>> listMembers() {
|
||||
return ApiResponse.success(userService.listMembers());
|
||||
}
|
||||
|
||||
private String resolveUserId(Authentication auth) {
|
||||
|
||||
@ -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<String, String> 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<JsonNode> 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);
|
||||
}
|
||||
}
|
||||
@ -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<JsonNode> 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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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<UserProfile> searchUsers(String appKey, String keyword) {
|
||||
public List<UserProfile> 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<UserProfile> listMembers(String appKey) {
|
||||
return userRepository.findAllByAppKeyOrderByCreatedAtAsc(appKey)
|
||||
public List<UserProfile> 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(),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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<String, String> headers) {
|
||||
if (headers == null || headers.isEmpty()) return new String[0];
|
||||
String[] pairs = new String[headers.size() * 2];
|
||||
int index = 0;
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
|
||||
@ -35,6 +35,7 @@ public class SecurityConfig {
|
||||
"/api/auth/**",
|
||||
"/api/sdk/**",
|
||||
"/api/internal/sdk/**",
|
||||
"/api/internal/im/**",
|
||||
"/actuator/health",
|
||||
"/actuator/info"
|
||||
).permitAll()
|
||||
|
||||
@ -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<String, String> 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<String, String> 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<String, String> 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<String, Object> 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<String, String> 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");
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<String> 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<String, Object> 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<String> storeTypes) throws Exception {
|
||||
Set<String> storeSet = new HashSet<>(storeTypes);
|
||||
List<AppVersionEntity> 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<String, Object> 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<String, Object> 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<String> storeTypes) throws Exception {
|
||||
Set<String> storeSet = new HashSet<>(storeTypes);
|
||||
List<AppVersionEntity> 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<String, Object> 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());
|
||||
}
|
||||
|
||||
@ -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);
|
||||
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<String, String> 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<String> RESTRICTED_HTTP_HEADERS = Set.of(
|
||||
"host", "content-length", "connection", "transfer-encoding", "upgrade", "expect", "te", "trailer");
|
||||
|
||||
private void huaweiUploadFile(String uploadUrl, Map<String, String> 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<String> 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<String, Object> body = Map.of("files", List.of(Map.of("fileName", fileName, "fileDestUrl", objectId)));
|
||||
Map<String, Object> body = Map.of(
|
||||
"fileType", 5,
|
||||
"files", List.of(Map.of("fileName", safeFileName, "fileDestUrl", objectId)));
|
||||
ResponseEntity<Map> resp = rest.exchange(
|
||||
HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId,
|
||||
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||
Map<String, Object> respBody = requireBodyMap(resp.getBody(), "Huawei bind apk");
|
||||
List<Map<String, Object>> 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 ["<id>"]
|
||||
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<String, Object> 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<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", new FileSystemResource(file));
|
||||
ResponseEntity<Map> 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<String, Object> 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<String, String> params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", ""));
|
||||
String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, false, clientSecret);
|
||||
MultiValueMap<String, Object> 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<String> 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<String, String> extraFields = new LinkedHashMap<>();
|
||||
extraFields.put("type", "apk");
|
||||
extraFields.put("sign", uploadUrl.getOrDefault("sign", ""));
|
||||
java.net.http.HttpResponse<String> 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<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", new FileSystemResource(file));
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
ResponseEntity<String> 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<String> multipartUploadWithTimeout(
|
||||
String url,
|
||||
String fileFieldName,
|
||||
File file,
|
||||
Map<String, String> extraFields,
|
||||
Map<String, String> 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<String, String> 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<java.io.InputStream>() {
|
||||
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<String, Object>) 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 ["<id>"]. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private String extractHuaweiPkgId(Map<String, Object> 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<String, Object>) 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<Map<String, Object>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户