一大波改动

这个提交包含在:
XuqmGroup 2026-05-15 16:47:22 +08:00
父节点 fb8a9d453d
当前提交 b24e3669cb
共有 16 个文件被更改,包括 444 次插入305 次删除

查看文件

@ -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;
}
}