一大波改动
这个提交包含在:
父节点
fb8a9d453d
当前提交
b24e3669cb
@ -20,6 +20,11 @@
|
|||||||
<groupId>com.xuqm</groupId>
|
<groupId>com.xuqm</groupId>
|
||||||
<artifactId>common</artifactId>
|
<artifactId>common</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.xuqm</groupId>
|
||||||
|
<artifactId>im-sdk</artifactId>
|
||||||
|
<version>0.1.0-SNAPSHOT</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<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.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@ -45,8 +44,4 @@ public class SecurityConfig {
|
|||||||
return new BCryptPasswordEncoder();
|
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.common.model.ApiResponse;
|
||||||
import com.xuqm.demo.service.DemoAuthService;
|
import com.xuqm.demo.service.DemoAuthService;
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -19,9 +18,6 @@ public class DemoAuthController {
|
|||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ApiResponse<Map<String, Object>> register(@RequestBody RegisterRequest body) {
|
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()) {
|
if (body.userId() == null || body.userId().isBlank()) {
|
||||||
return ApiResponse.badRequest("userId is required");
|
return ApiResponse.badRequest("userId is required");
|
||||||
}
|
}
|
||||||
@ -29,17 +25,12 @@ public class DemoAuthController {
|
|||||||
return ApiResponse.badRequest("password must be at least 6 characters");
|
return ApiResponse.badRequest("password must be at least 6 characters");
|
||||||
}
|
}
|
||||||
|
|
||||||
DemoAuthService.AuthResult result = authService.register(
|
DemoAuthService.AuthResult result = authService.register(body.userId(), body.password(), body.nickname());
|
||||||
body.appKey(), body.userId(), body.password(), body.nickname());
|
|
||||||
|
|
||||||
return ApiResponse.success(buildResponse(result));
|
return ApiResponse.success(buildResponse(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ApiResponse<Map<String, Object>> login(@RequestBody LoginRequest body) {
|
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()) {
|
if (body.userId() == null || body.userId().isBlank()) {
|
||||||
return ApiResponse.badRequest("userId is required");
|
return ApiResponse.badRequest("userId is required");
|
||||||
}
|
}
|
||||||
@ -47,36 +38,32 @@ public class DemoAuthController {
|
|||||||
return ApiResponse.badRequest("password is required");
|
return ApiResponse.badRequest("password is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
DemoAuthService.AuthResult result = authService.login(
|
DemoAuthService.AuthResult result = authService.login(body.userId(), body.password());
|
||||||
body.appKey(), body.userId(), body.password());
|
|
||||||
|
|
||||||
return ApiResponse.success(buildResponse(result));
|
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")
|
@PostMapping("/reset-password")
|
||||||
public ApiResponse<Void> resetPassword(@RequestBody ResetPasswordRequest body) {
|
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()) {
|
if (body.userId() == null || body.userId().isBlank()) {
|
||||||
return ApiResponse.badRequest("userId is required");
|
return ApiResponse.badRequest("userId is required");
|
||||||
}
|
}
|
||||||
if (body.newPassword() == null || body.newPassword().length() < 6) {
|
if (body.newPassword() == null || body.newPassword().length() < 6) {
|
||||||
return ApiResponse.badRequest("password must be at least 6 characters");
|
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();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegisterRequest(String appKey, String userId, String password, String nickname) {}
|
private Map<String, Object> buildResponse(DemoAuthService.AuthResult result) {
|
||||||
public record LoginRequest(String appKey, String userId, String password) {}
|
return Map.of(
|
||||||
public record ResetPasswordRequest(String appKey, String userId, String newPassword) {}
|
"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")
|
@GetMapping("/user/profile")
|
||||||
public ApiResponse<DemoUserService.UserProfile> getProfile(
|
public ApiResponse<DemoUserService.UserProfile> getProfile(Authentication auth) {
|
||||||
@RequestParam String appKey,
|
return ApiResponse.success(userService.getProfile(resolveUserId(auth)));
|
||||||
Authentication auth) {
|
|
||||||
String userId = resolveUserId(auth);
|
|
||||||
return ApiResponse.success(userService.getProfile(appKey, userId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/user/profile")
|
@PutMapping("/user/profile")
|
||||||
public ApiResponse<DemoUserService.UserProfile> updateProfile(
|
public ApiResponse<DemoUserService.UserProfile> updateProfile(
|
||||||
@RequestParam String appKey,
|
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
@RequestBody UpdateProfileRequest body) {
|
@RequestBody UpdateProfileRequest body) {
|
||||||
String userId = resolveUserId(auth);
|
|
||||||
return ApiResponse.success(
|
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")
|
@PostMapping("/user/change-password")
|
||||||
public ApiResponse<Void> changePassword(
|
public ApiResponse<Void> changePassword(
|
||||||
@RequestParam String appKey,
|
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
@RequestBody ResetPasswordRequest body) {
|
@RequestBody ResetPasswordRequest body) {
|
||||||
String userId = resolveUserId(auth);
|
|
||||||
if (body.oldPassword() == null || body.newPassword() == null) {
|
if (body.oldPassword() == null || body.newPassword() == null) {
|
||||||
return ApiResponse.badRequest("oldPassword and newPassword are required");
|
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();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/users/search")
|
@GetMapping("/users/search")
|
||||||
public ApiResponse<List<DemoUserService.UserProfile>> searchUsers(
|
public ApiResponse<List<DemoUserService.UserProfile>> searchUsers(@RequestParam String keyword) {
|
||||||
@RequestParam String appKey,
|
return ApiResponse.success(userService.searchUsers(keyword));
|
||||||
@RequestParam String keyword) {
|
|
||||||
return ApiResponse.success(userService.searchUsers(appKey, keyword));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/users/members")
|
@GetMapping("/users/members")
|
||||||
public ApiResponse<List<DemoUserService.UserProfile>> listMembers(
|
public ApiResponse<List<DemoUserService.UserProfile>> listMembers() {
|
||||||
@RequestParam String appKey) {
|
return ApiResponse.success(userService.listMembers());
|
||||||
return ApiResponse.success(userService.listMembers(appKey));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveUserId(Authentication auth) {
|
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;
|
package com.xuqm.demo.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.security.AppRequestSignatureUtil;
|
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
|
import com.xuqm.common.security.UserSigUtil;
|
||||||
import com.xuqm.demo.entity.DemoUserEntity;
|
import com.xuqm.demo.entity.DemoUserEntity;
|
||||||
import com.xuqm.demo.repository.DemoUserRepository;
|
import com.xuqm.demo.repository.DemoUserRepository;
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -28,43 +17,38 @@ import java.util.UUID;
|
|||||||
@Service
|
@Service
|
||||||
public class DemoAuthService {
|
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 DemoUserRepository userRepository;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final PasswordEncoder passwordEncoder;
|
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,
|
public DemoAuthService(DemoUserRepository userRepository,
|
||||||
JwtUtil jwtUtil,
|
JwtUtil jwtUtil,
|
||||||
PasswordEncoder passwordEncoder,
|
PasswordEncoder passwordEncoder) {
|
||||||
RestTemplate restTemplate,
|
|
||||||
DemoAppSecretClient appSecretClient) {
|
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
this.restTemplate = restTemplate;
|
|
||||||
this.appSecretClient = appSecretClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record AuthResult(String demoToken, String imToken, UserProfile profile) {}
|
public record AuthResult(String demoToken, String userSig, long userSigExpireAt, UserProfile profile) {}
|
||||||
public record ImCredential(String token) {}
|
|
||||||
|
|
||||||
public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {}
|
public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AuthResult register(String appKey, String userId, String password, String nickname) {
|
public AuthResult register(String userId, String password, String nickname) {
|
||||||
if (userRepository.existsByAppKeyAndUserId(appKey, userId)) {
|
if (userRepository.existsByAppKeyAndUserId(configuredAppKey, userId)) {
|
||||||
throw new BusinessException(409, "User already exists: " + userId);
|
throw new BusinessException(409, "User already exists: " + userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
DemoUserEntity user = new DemoUserEntity();
|
DemoUserEntity user = new DemoUserEntity();
|
||||||
user.setId(UUID.randomUUID().toString());
|
user.setId(UUID.randomUUID().toString());
|
||||||
user.setAppKey(appKey);
|
user.setAppKey(configuredAppKey);
|
||||||
user.setUserId(userId);
|
user.setUserId(userId);
|
||||||
user.setPasswordHash(passwordEncoder.encode(password));
|
user.setPasswordHash(passwordEncoder.encode(password));
|
||||||
user.setNickname(nickname != null ? nickname : userId);
|
user.setNickname(nickname != null ? nickname : userId);
|
||||||
@ -72,91 +56,36 @@ public class DemoAuthService {
|
|||||||
user.setCreatedAt(Instant.now());
|
user.setCreatedAt(Instant.now());
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appKey, userId);
|
String demoToken = generateDemoToken(userId);
|
||||||
ImCredential imCredential = callImServiceLogin(appKey, userId);
|
String userSig = UserSigUtil.generate(configuredAppSecret, configuredAppKey, userId, USER_SIG_EXPIRE_SECONDS, "");
|
||||||
|
long userSigExpireAt = Instant.now().getEpochSecond() + USER_SIG_EXPIRE_SECONDS;
|
||||||
return new AuthResult(
|
return new AuthResult(demoToken, userSig, userSigExpireAt, toProfile(user));
|
||||||
demoToken,
|
|
||||||
imCredential.token(),
|
|
||||||
toProfile(user)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public AuthResult login(String appKey, String userId, String password) {
|
public AuthResult login(String userId, String password) {
|
||||||
DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId)
|
DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId)
|
||||||
.orElseThrow(() -> new BusinessException(401, "Invalid credentials"));
|
.orElseThrow(() -> new BusinessException(401, "Invalid credentials"));
|
||||||
|
|
||||||
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||||
throw new BusinessException(401, "Invalid credentials");
|
throw new BusinessException(401, "Invalid credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
String demoToken = generateDemoToken(appKey, userId);
|
String demoToken = generateDemoToken(userId);
|
||||||
ImCredential imCredential = callImServiceLogin(appKey, userId);
|
String userSig = UserSigUtil.generate(configuredAppSecret, configuredAppKey, userId, USER_SIG_EXPIRE_SECONDS, "");
|
||||||
|
long userSigExpireAt = Instant.now().getEpochSecond() + USER_SIG_EXPIRE_SECONDS;
|
||||||
return new AuthResult(
|
return new AuthResult(demoToken, userSig, userSigExpireAt, toProfile(user));
|
||||||
demoToken,
|
|
||||||
imCredential.token(),
|
|
||||||
toProfile(user)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void resetPassword(String appKey, String userId, String newPassword) {
|
public void resetPassword(String userId, String newPassword) {
|
||||||
DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId)
|
DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "User not found: " + userId));
|
.orElseThrow(() -> new BusinessException(404, "User not found: " + userId));
|
||||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateDemoToken(String appKey, String userId) {
|
private String generateDemoToken(String userId) {
|
||||||
return jwtUtil.generate(userId, Map.of("appKey", appKey, "role", "USER"));
|
return jwtUtil.generate(userId, Map.of("appKey", configuredAppKey, "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 UserProfile toProfile(DemoUserEntity 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.common.exception.BusinessException;
|
||||||
import com.xuqm.demo.entity.DemoUserEntity;
|
import com.xuqm.demo.entity.DemoUserEntity;
|
||||||
import com.xuqm.demo.repository.DemoUserRepository;
|
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.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@ -12,26 +15,34 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class DemoUserService {
|
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 DemoUserRepository userRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
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.userRepository = userRepository;
|
||||||
this.passwordEncoder = passwordEncoder;
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.sdkFactory = sdkFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {}
|
public record UserProfile(String appKey, String userId, String nickname, String avatar, String gender) {}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public UserProfile getProfile(String appKey, String userId) {
|
public UserProfile getProfile(String userId) {
|
||||||
DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId)
|
DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
||||||
return toProfile(user);
|
return toProfile(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserProfile updateProfile(String appKey, String userId, String nickname, String avatar, String gender) {
|
public UserProfile updateProfile(String userId, String nickname, String avatar, String gender) {
|
||||||
DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId)
|
DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
||||||
|
|
||||||
if (nickname != null && !nickname.isBlank()) {
|
if (nickname != null && !nickname.isBlank()) {
|
||||||
@ -47,14 +58,17 @@ public class DemoUserService {
|
|||||||
throw new BusinessException(400, "Invalid gender value: " + gender);
|
throw new BusinessException(400, "Invalid gender value: " + gender);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
|
syncImProfile(userId, user.getNickname(), user.getAvatar(),
|
||||||
|
user.getGender() != null ? user.getGender().name() : null);
|
||||||
|
|
||||||
return toProfile(user);
|
return toProfile(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void resetPassword(String appKey, String userId, String oldPassword, String newPassword) {
|
public void resetPassword(String userId, String oldPassword, String newPassword) {
|
||||||
DemoUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId)
|
DemoUserEntity user = userRepository.findByAppKeyAndUserId(configuredAppKey, userId)
|
||||||
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
.orElseThrow(() -> new BusinessException(404, "User not found"));
|
||||||
|
|
||||||
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
|
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
|
||||||
@ -69,24 +83,33 @@ public class DemoUserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<UserProfile> searchUsers(String appKey, String keyword) {
|
public List<UserProfile> searchUsers(String keyword) {
|
||||||
if (keyword == null || keyword.isBlank()) {
|
if (keyword == null || keyword.isBlank()) {
|
||||||
throw new BusinessException(400, "Search keyword must not be blank");
|
throw new BusinessException(400, "Search keyword must not be blank");
|
||||||
}
|
}
|
||||||
return userRepository.searchByKeyword(appKey, keyword.trim())
|
return userRepository.searchByKeyword(configuredAppKey, keyword.trim())
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toProfile)
|
.map(this::toProfile)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<UserProfile> listMembers(String appKey) {
|
public List<UserProfile> listMembers() {
|
||||||
return userRepository.findAllByAppKeyOrderByCreatedAtAsc(appKey)
|
return userRepository.findAllByAppKeyOrderByCreatedAtAsc(configuredAppKey)
|
||||||
.stream()
|
.stream()
|
||||||
.map(this::toProfile)
|
.map(this::toProfile)
|
||||||
.toList();
|
.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) {
|
private UserProfile toProfile(DemoUserEntity user) {
|
||||||
return new UserProfile(
|
return new UserProfile(
|
||||||
user.getAppKey(),
|
user.getAppKey(),
|
||||||
|
|||||||
@ -35,9 +35,10 @@ jwt:
|
|||||||
expiration: 3153600000000
|
expiration: 3153600000000
|
||||||
|
|
||||||
demo:
|
demo:
|
||||||
tenant-service-url: ${TENANT_SERVICE_URL:http://127.0.0.1:9001}
|
app-key: ${DEMO_APP_KEY}
|
||||||
internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token}
|
app-secret: ${DEMO_APP_SECRET}
|
||||||
im-service-url: ${IM_SERVICE_URL:http://127.0.0.1:8082}
|
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:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -1584,8 +1584,9 @@ public final class XuqmImServerSdk {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
HttpRequest.Builder builder = HttpRequest.newBuilder(uri)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json");
|
||||||
.headers(flatten(headers));
|
String[] flatHeaders = flatten(headers);
|
||||||
|
if (flatHeaders.length > 0) builder = builder.headers(flatHeaders);
|
||||||
if (body != null) {
|
if (body != null) {
|
||||||
builder.method(method, HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
builder.method(method, HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
||||||
} else if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
} else if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
|
||||||
@ -1843,6 +1844,7 @@ public final class XuqmImServerSdk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String[] flatten(Map<String, String> headers) {
|
private String[] flatten(Map<String, String> headers) {
|
||||||
|
if (headers == null || headers.isEmpty()) return new String[0];
|
||||||
String[] pairs = new String[headers.size() * 2];
|
String[] pairs = new String[headers.size() * 2];
|
||||||
int index = 0;
|
int index = 0;
|
||||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||||
|
|||||||
@ -35,6 +35,7 @@ public class SecurityConfig {
|
|||||||
"/api/auth/**",
|
"/api/auth/**",
|
||||||
"/api/sdk/**",
|
"/api/sdk/**",
|
||||||
"/api/internal/sdk/**",
|
"/api/internal/sdk/**",
|
||||||
|
"/api/internal/im/**",
|
||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/actuator/info"
|
"/actuator/info"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
|
|||||||
@ -30,6 +30,9 @@ public class ImPlatformEventService {
|
|||||||
@Value("${sdk.im-platform-events-admin-user:admin}")
|
@Value("${sdk.im-platform-events-admin-user:admin}")
|
||||||
private String platformEventsAdminUser;
|
private String platformEventsAdminUser;
|
||||||
|
|
||||||
|
@Value("${sdk.im-platform-app-key:ak_409e217e4aa14254ad73ad3c}")
|
||||||
|
private String imPlatformAppKey;
|
||||||
|
|
||||||
public ImPlatformEventService(SdkAppProvisioningService provisioningService,
|
public ImPlatformEventService(SdkAppProvisioningService provisioningService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.provisioningService = provisioningService;
|
this.provisioningService = provisioningService;
|
||||||
@ -37,24 +40,24 @@ public class ImPlatformEventService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> issueToken(String appKey) throws Exception {
|
public Map<String, String> issueToken(String appKey) throws Exception {
|
||||||
AppEntity app = provisioningService.resolveApp(appKey);
|
AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey);
|
||||||
String userId = platformEventsRecipientUserId();
|
String userId = platformEventsRecipientUserId();
|
||||||
log.info("IM platform event token login start appKey={} userId={}", app.getAppKey(), userId);
|
log.info("IM platform event token login start platformAppKey={} userId={}", platformApp.getAppKey(), userId);
|
||||||
String token = requestImToken(app, userId);
|
String token = requestImToken(platformApp, userId);
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
result.put("appKey", app.getAppKey());
|
result.put("appKey", platformApp.getAppKey());
|
||||||
result.put("userId", userId);
|
result.put("userId", userId);
|
||||||
result.put("token", token);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception {
|
public Map<String, String> notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception {
|
||||||
AppEntity app = provisioningService.resolveApp(request.appKey());
|
AppEntity platformApp = provisioningService.resolveApp(imPlatformAppKey);
|
||||||
String recipientUserId = platformEventsRecipientUserId();
|
String recipientUserId = platformEventsRecipientUserId();
|
||||||
String senderUserId = platformEventsAdminUserId();
|
String senderUserId = platformEventsAdminUserId();
|
||||||
String senderToken = requestImToken(app, senderUserId);
|
String senderToken = requestImToken(platformApp, senderUserId);
|
||||||
XuqmImServerSdk sdk = sdk(app, senderToken);
|
XuqmImServerSdk sdk = sdk(platformApp, senderToken);
|
||||||
|
|
||||||
Map<String, Object> contentPayload = new LinkedHashMap<>();
|
Map<String, Object> contentPayload = new LinkedHashMap<>();
|
||||||
contentPayload.put("event", request.event() == null || request.event().isBlank() ? "store_review_update" : request.event());
|
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());
|
contentPayload.put("timestamp", System.currentTimeMillis());
|
||||||
String content = objectMapper.writeValueAsString(contentPayload);
|
String content = objectMapper.writeValueAsString(contentPayload);
|
||||||
|
|
||||||
log.info("IM platform event send appKey={} recipient={} sender={} event={} storeType={} state={} stage={} batchId={}",
|
log.info("IM platform event send platformAppKey={} recipient={} sender={} event={} storeType={} state={} stage={} batchId={}",
|
||||||
app.getAppKey(), recipientUserId, senderUserId,
|
platformApp.getAppKey(), recipientUserId, senderUserId,
|
||||||
request.event() == null || request.event().isBlank() ? "store_review_update" : request.event(),
|
request.event() == null || request.event().isBlank() ? "store_review_update" : request.event(),
|
||||||
request.storeType(), request.reviewState(), request.stage(), request.batchId());
|
request.storeType(), request.reviewState(), request.stage(), request.batchId());
|
||||||
var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest(
|
var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest(
|
||||||
@ -82,32 +85,32 @@ public class ImPlatformEventService {
|
|||||||
content,
|
content,
|
||||||
null
|
null
|
||||||
));
|
));
|
||||||
log.info("IM platform event message sent appKey={} recipient={} messageId={}",
|
log.info("IM platform event message sent platformAppKey={} recipient={} messageId={}",
|
||||||
app.getAppKey(), recipientUserId, message.id());
|
platformApp.getAppKey(), recipientUserId, message.id());
|
||||||
|
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
result.put("appKey", app.getAppKey());
|
result.put("appKey", platformApp.getAppKey());
|
||||||
result.put("userId", recipientUserId);
|
result.put("userId", recipientUserId);
|
||||||
result.put("messageId", message.id());
|
result.put("messageId", message.id());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private XuqmImServerSdk sdk(AppEntity app, String bearerToken) {
|
private XuqmImServerSdk sdk(AppEntity platformApp, String bearerToken) {
|
||||||
return XuqmImServerSdk.builder()
|
return XuqmImServerSdk.builder()
|
||||||
.baseUrl(imApiUrl)
|
.baseUrl(imApiUrl)
|
||||||
.appKey(app.getAppKey())
|
.appKey(platformApp.getAppKey())
|
||||||
.appSecret(app.getAppSecret())
|
.appSecret(platformApp.getAppSecret())
|
||||||
.bearerTokenSupplier(() -> bearerToken)
|
.bearerTokenSupplier(() -> bearerToken)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String requestImToken(AppEntity app, String userId) throws Exception {
|
private String requestImToken(AppEntity platformApp, String userId) throws Exception {
|
||||||
XuqmImServerSdk sdk = XuqmImServerSdk.builder()
|
XuqmImServerSdk sdk = XuqmImServerSdk.builder()
|
||||||
.baseUrl(imApiUrl)
|
.baseUrl(imApiUrl)
|
||||||
.appKey(app.getAppKey())
|
.appKey(platformApp.getAppKey())
|
||||||
.appSecret(app.getAppSecret())
|
.appSecret(platformApp.getAppSecret())
|
||||||
.build();
|
.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();
|
String token = sdk.loginWithUserSig(userId, userSig).token();
|
||||||
if (token == null || token.isBlank()) {
|
if (token == null || token.isBlank()) {
|
||||||
throw new IllegalStateException("Failed to issue IM token: empty token");
|
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-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-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform}
|
||||||
im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}
|
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 Platform { ANDROID, IOS, HARMONY }
|
||||||
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
||||||
/** Per-store review state used in storeReviewStatus JSON values. */
|
/** 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.
|
* Gray release mode.
|
||||||
* PERCENT: deterministic hash-based percentage of all users.
|
* 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 Logger log = LoggerFactory.getLogger(AppStoreService.class);
|
||||||
private static final ObjectMapper mapper = new ObjectMapper();
|
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 HttpClient http = HttpClient.newHttpClient();
|
||||||
|
|
||||||
private final AppStoreConfigRepository configRepo;
|
private final AppStoreConfigRepository configRepo;
|
||||||
@ -124,6 +125,11 @@ public class AppStoreService {
|
|||||||
throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED");
|
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<>();
|
Map<String, Object> reviewMap = new LinkedHashMap<>();
|
||||||
for (String store : resolvedTargets) {
|
for (String store : resolvedTargets) {
|
||||||
reviewMap.put(store, reviewPayload(
|
reviewMap.put(store, reviewPayload(
|
||||||
@ -512,6 +518,7 @@ public class AppStoreService {
|
|||||||
case REJECTED -> "FAILED";
|
case REJECTED -> "FAILED";
|
||||||
case SUBMITTING -> "SUBMITTING";
|
case SUBMITTING -> "SUBMITTING";
|
||||||
case PENDING -> "QUEUED";
|
case PENDING -> "QUEUED";
|
||||||
|
case WITHDRAWN -> "WITHDRAWN";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,6 +536,78 @@ public class AppStoreService {
|
|||||||
return value.toString();
|
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) {
|
private Object lockFor(String versionId) {
|
||||||
return versionLocks.computeIfAbsent(versionId, ignored -> new Object());
|
return versionLocks.computeIfAbsent(versionId, ignored -> new Object());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -210,16 +210,20 @@ public class StoreSubmissionService {
|
|||||||
rejectedCount.incrementAndGet();
|
rejectedCount.incrementAndGet();
|
||||||
String message = describeException(e);
|
String message = describeException(e);
|
||||||
log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), 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(
|
try {
|
||||||
"durationMs", System.currentTimeMillis() - plan.storeStartedAt,
|
recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of(
|
||||||
"phase", "SUBMISSION",
|
"durationMs", System.currentTimeMillis() - plan.storeStartedAt,
|
||||||
"errorClass", e.getClass().getName(),
|
"phase", "SUBMISSION",
|
||||||
"reason", message
|
"errorClass", e.getClass().getName(),
|
||||||
), message);
|
"reason", message
|
||||||
|
), message);
|
||||||
|
} catch (Exception logEx) {
|
||||||
|
log.warn("Failed to record store event for {}/{}: {}", v.getAppKey(), plan.storeType, logEx.getMessage());
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
storeService.updateStoreReview(versionId, plan.storeType,
|
storeService.updateStoreReview(versionId, plan.storeType,
|
||||||
AppVersionEntity.StoreReviewState.REJECTED,
|
AppVersionEntity.StoreReviewState.REJECTED,
|
||||||
message);
|
message.length() > 500 ? message.substring(0, 500) : message);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
log.warn("Failed to persist rejection for {}/{} batchId={}: {}",
|
log.warn("Failed to persist rejection for {}/{} batchId={}: {}",
|
||||||
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
|
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
|
||||||
@ -230,9 +234,9 @@ public class StoreSubmissionService {
|
|||||||
if (!futures.isEmpty()) {
|
if (!futures.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
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) {
|
} 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));
|
futures.forEach(f -> f.cancel(true));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Store submit batch wait error for version={}: {}", versionId, e.getMessage());
|
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");
|
return requireBodyMap(resp.getBody(), "Huawei upload url");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void huaweiUploadFile(String uploadUrl, Map<String, String> extraHeaders, File file) {
|
private static final Set<String> RESTRICTED_HTTP_HEADERS = Set.of(
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"host", "content-length", "connection", "transfer-encoding", "upgrade", "expect", "te", "trailer");
|
||||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
|
||||||
if (extraHeaders != null) extraHeaders.forEach(headers::set);
|
private void huaweiUploadFile(String uploadUrl, Map<String, String> extraHeaders, File file) throws Exception {
|
||||||
FileSystemResource resource = new FileSystemResource(file);
|
java.net.http.HttpRequest.Builder builder = java.net.http.HttpRequest.newBuilder()
|
||||||
rest.exchange(uploadUrl, HttpMethod.PUT, new HttpEntity<>(resource, headers), Void.class);
|
.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")
|
@SuppressWarnings("unchecked")
|
||||||
private String huaweiBindApk(String clientId, String token, String hwAppId, String fileName, String objectId) {
|
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);
|
HttpHeaders headers = huaweiHeaders(clientId, token);
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
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(
|
ResponseEntity<Map> resp = rest.exchange(
|
||||||
HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId,
|
HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId,
|
||||||
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class);
|
||||||
Map<String, Object> respBody = requireBodyMap(resp.getBody(), "Huawei bind apk");
|
Map<String, Object> respBody = requireBodyMap(resp.getBody(), "Huawei bind apk");
|
||||||
List<Map<String, Object>> pkgList = asMapList(respBody.get("pkgVersion"));
|
// pkgVersion may be a list of maps {"id":...} or a plain string list ["<id>"]
|
||||||
if (pkgList.isEmpty()) {
|
String pkgId = extractHuaweiPkgId(respBody, "pkgVersion");
|
||||||
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");
|
|
||||||
if (pkgId.isBlank()) {
|
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;
|
return pkgId;
|
||||||
}
|
}
|
||||||
@ -473,14 +501,20 @@ public class StoreSubmissionService {
|
|||||||
Map<String, Object> uploadInfo = honorGetUploadUrl(token, honorAppId, file, fileSha256);
|
Map<String, Object> uploadInfo = honorGetUploadUrl(token, honorAppId, file, fileSha256);
|
||||||
long objectId = ((Number) uploadInfo.get("objectId")).longValue();
|
long objectId = ((Number) uploadInfo.get("objectId")).longValue();
|
||||||
|
|
||||||
// 4. Upload file via multipart
|
// 4. Upload file via multipart — get fresh token immediately before upload so the
|
||||||
honorUploadFile(token, honorAppId, objectId, file);
|
// 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
|
// 5. Bind APK file info — get fresh token in case upload exhausted the previous one
|
||||||
honorUpdateFileInfo(token, honorAppId, objectId);
|
String bindToken = honorGetToken(clientId, clientSecret);
|
||||||
|
honorUpdateFileInfo(bindToken, honorAppId, objectId);
|
||||||
|
|
||||||
// 6. Submit for review
|
// 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) {
|
private String honorGetToken(String clientId, String clientSecret) {
|
||||||
@ -530,15 +564,31 @@ public class StoreSubmissionService {
|
|||||||
return list.get(0);
|
return list.get(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void honorUploadFile(String token, int appId, long objectId, File file) {
|
@SuppressWarnings("unchecked")
|
||||||
HttpHeaders headers = honorHeaders(token);
|
private void honorUploadFile(String token, int appId, long objectId, File file) throws Exception {
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
String url = HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId;
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
// curl sends Expect:100-continue so HONOR validates the token during header negotiation
|
||||||
body.add("file", new FileSystemResource(file));
|
// (before the 430MB body arrives), avoiding token-expiry after a long transfer.
|
||||||
ResponseEntity<Map> resp = rest.postForEntity(
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
HONOR_API + "/openapi/v1/publish/file-upload?appId=" + appId + "&objectId=" + objectId,
|
"curl", "-s", "--connect-timeout", "30",
|
||||||
new HttpEntity<>(body, headers), Map.class);
|
"--max-time", String.valueOf(130 * 60),
|
||||||
assertHonorSuccess(resp.getBody(), "file-upload");
|
"-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) {
|
private void honorUpdateFileInfo(String token, int appId, long objectId) {
|
||||||
@ -793,15 +843,14 @@ public class StoreSubmissionService {
|
|||||||
File file,
|
File file,
|
||||||
String clientSecret) throws Exception {
|
String clientSecret) throws Exception {
|
||||||
Map<String, String> params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", ""));
|
Map<String, String> params = Map.of("type", "apk", "sign", uploadUrl.getOrDefault("sign", ""));
|
||||||
String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, false, clientSecret);
|
// paramsAppendQuery=true: put type+sign in URL so CDN can verify api_sign from URL params alone
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
String requestUrl = oppoRequestUrl(uploadUrl.getOrDefault("url", ""), params, token, true, clientSecret);
|
||||||
body.add("file", new FileSystemResource(file));
|
Map<String, String> extraFields = new LinkedHashMap<>();
|
||||||
body.add("type", "apk");
|
extraFields.put("type", "apk");
|
||||||
body.add("sign", uploadUrl.getOrDefault("sign", ""));
|
extraFields.put("sign", uploadUrl.getOrDefault("sign", ""));
|
||||||
HttpHeaders headers = new HttpHeaders();
|
java.net.http.HttpResponse<String> response = multipartUploadWithTimeout(
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
requestUrl, "file", file, extraFields, null);
|
||||||
ResponseEntity<String> response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class);
|
JsonNode root = mapper.readTree(response.body());
|
||||||
JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody()));
|
|
||||||
oppoCheckSuccess(root, "上传Apk");
|
oppoCheckSuccess(root, "上传Apk");
|
||||||
return root.path("data");
|
return root.path("data");
|
||||||
}
|
}
|
||||||
@ -906,12 +955,26 @@ public class StoreSubmissionService {
|
|||||||
"fileMd5", md5Hex(file)
|
"fileMd5", md5Hex(file)
|
||||||
);
|
);
|
||||||
String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.upload.apk.app", params);
|
String requestUrl = vivoRequestUrl(accessKey, accessSecret, "app.upload.apk.app", params);
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
// Use curl via ProcessBuilder: curl sends Expect:100-continue for large files,
|
||||||
body.add("file", new FileSystemResource(file));
|
// bypassing VIVO API gateway's ~3.5min request body timeout on direct streaming.
|
||||||
HttpHeaders headers = new HttpHeaders();
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
"curl", "-s", "--connect-timeout", "30",
|
||||||
ResponseEntity<String> response = rest.postForEntity(requestUrl, new HttpEntity<>(body, headers), String.class);
|
"--max-time", String.valueOf(130 * 60),
|
||||||
JsonNode root = mapper.readTree(Objects.requireNonNull(response.getBody()));
|
"-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");
|
vivoCheckSuccess(root, "上传apk");
|
||||||
return root.path("data");
|
return root.path("data");
|
||||||
}
|
}
|
||||||
@ -969,10 +1032,78 @@ public class StoreSubmissionService {
|
|||||||
org.springframework.http.client.SimpleClientHttpRequestFactory factory =
|
org.springframework.http.client.SimpleClientHttpRequestFactory factory =
|
||||||
new org.springframework.http.client.SimpleClientHttpRequestFactory();
|
new org.springframework.http.client.SimpleClientHttpRequestFactory();
|
||||||
factory.setConnectTimeout(30_000);
|
factory.setConnectTimeout(30_000);
|
||||||
factory.setReadTimeout(300_000); // 5 minutes for large APK uploads
|
factory.setReadTimeout(300_000);
|
||||||
return new RestTemplate(factory);
|
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) {
|
private String vivoEncodeValue(String value) {
|
||||||
if (value == null) return "";
|
if (value == null) return "";
|
||||||
try {
|
try {
|
||||||
@ -1123,7 +1254,21 @@ public class StoreSubmissionService {
|
|||||||
return (Map<String, Object>) body;
|
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")
|
@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) {
|
private List<Map<String, Object>> asMapList(Object value) {
|
||||||
if (!(value instanceof List<?> list)) {
|
if (!(value instanceof List<?> list)) {
|
||||||
return List.of();
|
return List.of();
|
||||||
@ -1248,6 +1393,7 @@ public class StoreSubmissionService {
|
|||||||
if (message == null || message.isBlank()) {
|
if (message == null || message.isBlank()) {
|
||||||
message = root.getClass().getSimpleName();
|
message = root.getClass().getSimpleName();
|
||||||
}
|
}
|
||||||
return root.getClass().getSimpleName() + ": " + message;
|
String full = root.getClass().getSimpleName() + ": " + message;
|
||||||
|
return full.length() > 900 ? full.substring(0, 900) + "..." : full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户