一大波改动

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

查看文件

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