From 1a1892503458aa5a6a6f183ac43e53e51d667106 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 14 May 2026 23:40:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8E=82=E5=95=86=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=95=86=E5=BA=97=E6=8F=90=E4=BA=A4=E5=8A=9F=E8=83=BD=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8F=8Apush=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-service: - 修复华为 appId 解析 NPE(支持直接从响应体顶层读取 value 字段) - 修复 OPPO 更新描述不足5字符时自动补空格 - 修复 VIVO 签名中文字符需 URL 编码 - 修复 RestTemplate 无超时(30s连接/5min读取) - AppVersionEntity 添加 grayCallbackUrl 字段 tenant-service: - FeatureServiceController switch 添加 FILE 分支(修复编译错误) - FeatureServiceManager 添加 buildFileConfig 方法 - AppController 添加应用用户列表代理端点 - AppUserClient 新增 IM/Push 用户列表客户端 push-service: - 新增 PushUserEntity/PushUserRepository/PushAccountService - 新增 PushAuthController(内部鉴权接口) - PushManagementController 添加用户管理接口 - PushAppSecretClient 对接 tenant-service 鉴权 im-service: - ImAccountRepository/ImAccountService 添加用户搜索接口 - ImAdminController 添加管理端用户列表 - InternalPresenceController 完善在线状态接口 Co-Authored-By: Claude Sonnet 4.6 --- .../xuqm/im/controller/ImAdminController.java | 7 -- .../InternalPresenceController.java | 39 +++++- .../im/repository/ImAccountRepository.java | 7 ++ .../com/xuqm/im/service/ImAccountService.java | 26 +++- .../com/xuqm/push/config/SecurityConfig.java | 2 +- .../controller/InternalPushController.java | 39 +++++- .../push/controller/PushAuthController.java | 56 +++++++++ .../controller/PushManagementController.java | 98 +++++++++++++-- .../com/xuqm/push/entity/PushUserEntity.java | 69 +++++++++++ .../push/repository/PushUserRepository.java | 26 ++++ .../xuqm/push/service/PushAccountService.java | 114 ++++++++++++++++++ .../push/service/PushAppSecretClient.java | 59 +++++++++ .../xuqm/tenant/controller/AppController.java | 38 +++++- .../controller/FeatureServiceController.java | 1 + .../tenant/entity/FeatureServiceEntity.java | 2 +- .../xuqm/tenant/entity/OpsAdminEntity.java | 12 ++ .../com/xuqm/tenant/service/AppService.java | 22 +++- .../xuqm/tenant/service/AppUserClient.java | 72 +++++++++++ .../tenant/service/FeatureServiceManager.java | 17 ++- .../controller/AppVersionController.java | 106 +++++++++++----- .../update/controller/RnBundleController.java | 109 ++++++++++++----- .../xuqm/update/entity/AppVersionEntity.java | 27 ++++- .../xuqm/update/entity/RnBundleEntity.java | 16 ++- .../xuqm/update/service/ImPushUserClient.java | 73 +++++++++++ .../update/service/PublishConfigService.java | 35 +++++- .../service/StoreSubmissionService.java | 35 +++++- 26 files changed, 1011 insertions(+), 96 deletions(-) create mode 100644 push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java create mode 100644 push-service/src/main/java/com/xuqm/push/entity/PushUserEntity.java create mode 100644 push-service/src/main/java/com/xuqm/push/repository/PushUserRepository.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/PushAccountService.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/PushAppSecretClient.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/AppUserClient.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index 2aa4ad7..caeb96a 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -183,9 +183,6 @@ public class ImAdminController { @AuthenticationPrincipal String operatorId, @RequestBody(required = false) UserSigRequest req) { ImAccountEntity account = accountService.getAccount(appKey, userId); - if (!account.isAdmin()) { - throw new BusinessException(403, "Only admin accounts can generate UserSig for service-side usage"); - } long expireSeconds = req == null || req.expireSeconds() == null ? 180L * 24 * 60 * 60 : Math.max(req.expireSeconds(), 60L); String userBuf = req == null ? "" : (req.userBuf() == null ? "" : req.userBuf()); String userSig = accountService.generateUserSigToken(appKey, userId, expireSeconds, userBuf); @@ -208,10 +205,6 @@ public class ImAdminController { @PathVariable String userId, @AuthenticationPrincipal String operatorId, @RequestBody UserSigVerifyRequest req) { - ImAccountEntity account = accountService.getAccount(appKey, userId); - if (!account.isAdmin()) { - throw new BusinessException(403, "Only admin accounts can verify service-side UserSig"); - } UserSigUtil.UserSigClaims claims = accountService.verifyUserSig(appKey, userId, req.userSig()); operationLogService.record(appKey, operatorId, "VERIFY_USERSIG", "ACCOUNT", userId, "ok"); return ResponseEntity.ok(ApiResponse.success(Map.of( diff --git a/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java b/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java index 24373bf..449bce2 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/InternalPresenceController.java @@ -2,6 +2,7 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.common.security.JwtUtil; +import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.UserPresenceService; import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Value; @@ -15,19 +16,25 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @RestController @RequestMapping("/api/im/internal/presence") public class InternalPresenceController { private final JwtUtil jwtUtil; private final UserPresenceService presenceService; + private final ImAccountService accountService; @Value("${im.internal-token:xuqm-internal-token}") private String internalToken; - public InternalPresenceController(JwtUtil jwtUtil, UserPresenceService presenceService) { + public InternalPresenceController(JwtUtil jwtUtil, + UserPresenceService presenceService, + ImAccountService accountService) { this.jwtUtil = jwtUtil; this.presenceService = presenceService; + this.accountService = accountService; } @PostMapping("/resolve-token") @@ -63,6 +70,36 @@ public class InternalPresenceController { return new PresenceStatus(appKey, userId, online, presenceService.lastSeenAt(userId)); } + @GetMapping("/accounts") + public ResponseEntity>> listAccounts( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestParam String appKey, + @RequestParam(required = false, defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + org.springframework.data.domain.Page result = + accountService.listAccounts(appKey, keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @GetMapping("/accounts/exists") + public ResponseEntity>> accountExists( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestParam String appKey, + @RequestParam String userId) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(Map.of("exists", accountService.exists(appKey, userId)))); + } + private boolean isAllowed(String token) { return token != null && internalToken.equals(token); } diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java index caebf44..64e145b 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImAccountRepository.java @@ -18,4 +18,11 @@ public interface ImAccountRepository extends JpaRepository searchByKeyword(@Param("appKey") String appKey, @Param("kw") String keyword, Pageable pageable); + + @Query("SELECT a FROM ImAccountEntity a WHERE a.appKey = :appKey AND " + + "(LOWER(a.userId) LIKE LOWER(CONCAT('%', :kw, '%')) OR " + + "LOWER(a.nickname) LIKE LOWER(CONCAT('%', :kw, '%')))") + Page findByAppKeyAndKeyword(@Param("appKey") String appKey, + @Param("kw") String keyword, + Pageable pageable); } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java index 05cbd87..b1a5845 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -46,7 +46,23 @@ public class ImAccountService { public LoginResult loginWithUserSig(String appKey, String userId, String userSig) { UserSigUtil.verify(appSecretClient.getAppSecret(appKey), appKey, userId, userSig); - return login(appKey, userId); + ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId) + .orElseGet(() -> { + ImAccountEntity entity = new ImAccountEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(appKey); + entity.setUserId(userId); + entity.setNickname(userId); + entity.setGender(ImAccountEntity.Gender.UNKNOWN); + entity.setStatus(ImAccountEntity.Status.ACTIVE); + entity.setCreatedAt(LocalDateTime.now()); + return accountRepository.save(entity); + }); + if (account.getStatus() == ImAccountEntity.Status.BANNED) { + throw new BusinessException(403, "账号已被封禁"); + } + String role = account.isAdmin() ? "ADMIN" : "USER"; + return new LoginResult(jwtUtil.generate(userId, Map.of("appKey", appKey, "role", role)), account.isAdmin()); } public LoginResult login(String appKey, String userId) { @@ -134,6 +150,14 @@ public class ImAccountService { return accountRepository.searchByKeyword(appKey, keyword, PageRequest.of(0, Math.max(size, 1))); } + public org.springframework.data.domain.Page listAccounts(String appKey, String keyword, int page, int size) { + PageRequest pageable = PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), 100)); + if (keyword == null || keyword.isBlank()) { + return accountRepository.findByAppKey(appKey, pageable); + } + return accountRepository.findByAppKeyAndKeyword(appKey, keyword.trim(), pageable); + } + public record ImportAccountRequest( String userId, String nickname, diff --git a/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java b/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java index ec2ce24..329e409 100644 --- a/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java +++ b/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java @@ -27,7 +27,7 @@ public class SecurityConfig { .csrf(AbstractHttpConfigurer::disable) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/push/internal/**", "/actuator/health", "/actuator/info").permitAll() + .requestMatchers("/api/push/internal/**", "/api/push/auth/**", "/actuator/health", "/actuator/info").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); diff --git a/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java index 4b7b858..f635f4c 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java @@ -2,6 +2,7 @@ package com.xuqm.push.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.push.entity.DeviceLoginLogEntity; +import com.xuqm.push.service.PushAccountService; import com.xuqm.push.service.PushDiagnosticsService; import com.xuqm.push.service.PushDispatcher; import jakarta.validation.constraints.NotBlank; @@ -13,9 +14,11 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/push/internal") @@ -23,13 +26,17 @@ public class InternalPushController { private final PushDispatcher pushDispatcher; private final PushDiagnosticsService diagnosticsService; + private final PushAccountService accountService; @Value("${push.internal-token:xuqm-internal-token}") private String internalToken; - public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) { + public InternalPushController(PushDispatcher pushDispatcher, + PushDiagnosticsService diagnosticsService, + PushAccountService accountService) { this.pushDispatcher = pushDispatcher; this.diagnosticsService = diagnosticsService; + this.accountService = accountService; } @PostMapping("/notify") @@ -88,6 +95,36 @@ public class InternalPushController { return ResponseEntity.ok(ApiResponse.success(result)); } + @GetMapping("/accounts") + public ResponseEntity>> listAccounts( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestParam String appKey, + @RequestParam(required = false, defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + org.springframework.data.domain.Page result = + accountService.listUsers(appKey, keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @GetMapping("/accounts/exists") + public ResponseEntity>> accountExists( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestParam String appKey, + @RequestParam String userId) { + if (!isAllowed(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(Map.of("exists", accountService.exists(appKey, userId)))); + } + private boolean isAllowed(String token) { return token != null && internalToken.equals(token); } diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java b/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java new file mode 100644 index 0000000..91a774b --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/controller/PushAuthController.java @@ -0,0 +1,56 @@ +package com.xuqm.push.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.push.entity.DeviceTokenEntity; +import com.xuqm.push.service.PushAccountService; +import com.xuqm.push.service.PushDispatcher; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/push/auth") +public class PushAuthController { + + private final PushAccountService accountService; + private final PushDispatcher pushDispatcher; + + public PushAuthController(PushAccountService accountService, PushDispatcher pushDispatcher) { + this.accountService = accountService; + this.pushDispatcher = pushDispatcher; + } + + /** + * Login with userSig. On success, automatically registers the device push token. + * Returns a pushToken (JWT) used to authenticate subsequent push service requests. + */ + @PostMapping("/login") + public ResponseEntity>> login( + @RequestParam String appKey, + @RequestParam String userId, + @RequestParam String userSig, + @RequestParam DeviceTokenEntity.Vendor vendor, + @RequestParam String token, + @RequestParam(required = false) String platform, + @RequestParam(required = false) String deviceId, + @RequestParam(required = false) String brand, + @RequestParam(required = false) String model, + @RequestParam(required = false) String osVersion, + @RequestParam(required = false) String appVersion) { + + PushAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig); + pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion); + + Map response = Map.of( + "pushToken", result.pushToken(), + "userId", result.user().getUserId(), + "appKey", appKey, + "nickname", result.user().getNickname() != null ? result.user().getNickname() : "" + ); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java b/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java index 9cdb115..5a49f54 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java @@ -2,17 +2,23 @@ package com.xuqm.push.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.push.entity.DeviceLoginLogEntity; +import com.xuqm.push.entity.PushUserEntity; +import com.xuqm.push.service.PushAccountService; import com.xuqm.push.service.PushDiagnosticsService; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Map; @RestController @@ -21,11 +27,88 @@ import java.util.Map; public class PushManagementController { private final PushDiagnosticsService diagnosticsService; + private final PushAccountService accountService; - public PushManagementController(PushDiagnosticsService diagnosticsService) { + public PushManagementController(PushDiagnosticsService diagnosticsService, + PushAccountService accountService) { this.diagnosticsService = diagnosticsService; + this.accountService = accountService; } + // ---- user account management ---- + + @GetMapping("/users") + public ResponseEntity>> listUsers( + @RequestParam String appKey, + @RequestParam(required = false, defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = accountService.listUsers(appKey, keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "content", result.getContent(), + "total", result.getTotalElements(), + "totalPages", result.getTotalPages() + ))); + } + + @GetMapping("/users/{userId}") + public ResponseEntity> getUser( + @PathVariable String userId, + @RequestParam String appKey) { + return ResponseEntity.ok(ApiResponse.success(accountService.getAccount(appKey, userId))); + } + + @PutMapping("/users/{userId}") + public ResponseEntity> updateUser( + @PathVariable String userId, + @RequestBody UpdateUserRequest request) { + PushUserEntity.Gender gender = null; + if (request.gender() != null && !request.gender().isBlank()) { + try { gender = PushUserEntity.Gender.valueOf(request.gender().toUpperCase()); } catch (Exception ignored) {} + } + PushUserEntity updated = accountService.updateAccount(request.appKey(), userId, + request.nickname(), request.avatar(), gender); + return ResponseEntity.ok(ApiResponse.success(updated)); + } + + @PutMapping("/users/{userId}/status") + public ResponseEntity> setUserStatus( + @PathVariable String userId, + @RequestBody UserStatusRequest request) { + PushUserEntity.Status status; + try { + status = PushUserEntity.Status.valueOf(request.status().toUpperCase()); + } catch (Exception e) { + return ResponseEntity.badRequest().body(ApiResponse.error(400, "无效的状态值,可选:ACTIVE, BANNED")); + } + return ResponseEntity.ok(ApiResponse.success(accountService.setUserStatus(request.appKey(), userId, status))); + } + + @DeleteMapping("/users/{userId}") + public ResponseEntity> deleteUser( + @PathVariable String userId, + @RequestParam String appKey) { + accountService.deleteAccount(appKey, userId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/users/import") + public ResponseEntity> importUser(@RequestBody ImportUserRequest request) { + PushUserEntity.Gender gender = null; + if (request.gender() != null && !request.gender().isBlank()) { + try { gender = PushUserEntity.Gender.valueOf(request.gender().toUpperCase()); } catch (Exception ignored) {} + } + PushUserEntity.Status status = null; + if (request.status() != null && !request.status().isBlank()) { + try { status = PushUserEntity.Status.valueOf(request.status().toUpperCase()); } catch (Exception ignored) {} + } + PushUserEntity user = accountService.importAccount(request.appKey(), request.userId(), + request.nickname(), request.avatar(), gender, status); + return ResponseEntity.ok(ApiResponse.success(user)); + } + + // ---- diagnostics ---- + @GetMapping("/user-status") public ResponseEntity> userStatus( @RequestParam String appKey, @@ -59,11 +142,10 @@ public class PushManagementController { return ResponseEntity.ok(ApiResponse.success(result)); } - public record TestOfflineRequest( - String appKey, - String userId, - String title, - String body, - String payload - ) {} + public record UpdateUserRequest(String appKey, String nickname, String avatar, String gender) {} + public record UserStatusRequest(String appKey, String status) {} + public record ImportUserRequest(String appKey, String userId, String nickname, + String avatar, String gender, String status) {} + public record TestOfflineRequest(String appKey, String userId, + String title, String body, String payload) {} } diff --git a/push-service/src/main/java/com/xuqm/push/entity/PushUserEntity.java b/push-service/src/main/java/com/xuqm/push/entity/PushUserEntity.java new file mode 100644 index 0000000..6cdd4f4 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/entity/PushUserEntity.java @@ -0,0 +1,69 @@ +package com.xuqm.push.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +@Entity +@Table(name = "push_user", + uniqueConstraints = @UniqueConstraint(columnNames = {"appKey", "userId"})) +public class PushUserEntity { + + public enum Gender { UNKNOWN, MALE, FEMALE } + public enum Status { ACTIVE, BANNED } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appKey; + + @Column(nullable = false, length = 128) + private String userId; + + @Column(length = 64) + private String nickname; + + @Enumerated(EnumType.STRING) + @Column(length = 16) + private Gender gender; + + @Column(length = 512) + private String avatar; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private Status status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppKey() { return appKey; } + public void setAppKey(String appKey) { this.appKey = appKey; } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + + public String getNickname() { return nickname; } + public void setNickname(String nickname) { this.nickname = nickname; } + + public Gender getGender() { return gender; } + public void setGender(Gender gender) { this.gender = gender; } + + public String getAvatar() { return avatar; } + public void setAvatar(String avatar) { this.avatar = avatar; } + + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/push-service/src/main/java/com/xuqm/push/repository/PushUserRepository.java b/push-service/src/main/java/com/xuqm/push/repository/PushUserRepository.java new file mode 100644 index 0000000..49d131b --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/repository/PushUserRepository.java @@ -0,0 +1,26 @@ +package com.xuqm.push.repository; + +import com.xuqm.push.entity.PushUserEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface PushUserRepository extends JpaRepository { + + Optional findByAppKeyAndUserId(String appKey, String userId); + + boolean existsByAppKeyAndUserId(String appKey, String userId); + + Page findByAppKey(String appKey, Pageable pageable); + + @Query("SELECT u FROM PushUserEntity u WHERE u.appKey = :appKey AND " + + "(LOWER(u.userId) LIKE LOWER(CONCAT('%', :kw, '%')) OR " + + "LOWER(u.nickname) LIKE LOWER(CONCAT('%', :kw, '%')))") + Page findByAppKeyAndKeyword(@Param("appKey") String appKey, + @Param("kw") String keyword, + Pageable pageable); +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushAccountService.java b/push-service/src/main/java/com/xuqm/push/service/PushAccountService.java new file mode 100644 index 0000000..79dc4c3 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/PushAccountService.java @@ -0,0 +1,114 @@ +package com.xuqm.push.service; + +import com.xuqm.common.exception.BusinessException; +import com.xuqm.common.security.JwtUtil; +import com.xuqm.common.security.UserSigUtil; +import com.xuqm.push.entity.PushUserEntity; +import com.xuqm.push.repository.PushUserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.UUID; + +@Service +public class PushAccountService { + + private final PushUserRepository userRepository; + private final PushAppSecretClient appSecretClient; + private final JwtUtil jwtUtil; + + public PushAccountService(PushUserRepository userRepository, + PushAppSecretClient appSecretClient, + JwtUtil jwtUtil) { + this.userRepository = userRepository; + this.appSecretClient = appSecretClient; + this.jwtUtil = jwtUtil; + } + + public record LoginResult(String pushToken, PushUserEntity user) {} + + public LoginResult loginWithUserSig(String appKey, String userId, String userSig) { + UserSigUtil.verify(appSecretClient.getAppSecret(appKey), appKey, userId, userSig); + + PushUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + .orElseGet(() -> { + PushUserEntity entity = new PushUserEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(appKey); + entity.setUserId(userId); + entity.setNickname(userId); + entity.setGender(PushUserEntity.Gender.UNKNOWN); + entity.setStatus(PushUserEntity.Status.ACTIVE); + entity.setCreatedAt(LocalDateTime.now()); + return userRepository.save(entity); + }); + + if (user.getStatus() == PushUserEntity.Status.BANNED) { + throw new BusinessException(403, "账号已被封禁"); + } + + String pushToken = jwtUtil.generate(userId, Map.of("appKey", appKey, "role", "USER")); + return new LoginResult(pushToken, user); + } + + public boolean exists(String appKey, String userId) { + return userRepository.existsByAppKeyAndUserId(appKey, userId); + } + + public PushUserEntity getAccount(String appKey, String userId) { + return userRepository.findByAppKeyAndUserId(appKey, userId) + .orElseThrow(() -> new BusinessException(404, "账号不存在")); + } + + public PushUserEntity updateAccount(String appKey, String userId, + String nickname, String avatar, PushUserEntity.Gender gender) { + PushUserEntity user = getAccount(appKey, userId); + if (nickname != null) user.setNickname(nickname); + if (avatar != null) user.setAvatar(avatar); + if (gender != null) user.setGender(gender); + return userRepository.save(user); + } + + public Page listUsers(String appKey, String keyword, int page, int size) { + PageRequest pageable = PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), 100), + Sort.by("createdAt").descending()); + if (keyword == null || keyword.isBlank()) { + return userRepository.findByAppKey(appKey, pageable); + } + return userRepository.findByAppKeyAndKeyword(appKey, keyword.trim(), pageable); + } + + public PushUserEntity setUserStatus(String appKey, String userId, PushUserEntity.Status status) { + PushUserEntity user = getAccount(appKey, userId); + user.setStatus(status); + return userRepository.save(user); + } + + public PushUserEntity importAccount(String appKey, String userId, String nickname, + String avatar, PushUserEntity.Gender gender, + PushUserEntity.Status status) { + PushUserEntity user = userRepository.findByAppKeyAndUserId(appKey, userId) + .orElseGet(() -> { + PushUserEntity entity = new PushUserEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(appKey); + entity.setUserId(userId); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + }); + user.setNickname(nickname != null ? nickname : userId); + if (avatar != null) user.setAvatar(avatar); + user.setGender(gender != null ? gender : PushUserEntity.Gender.UNKNOWN); + user.setStatus(status != null ? status : PushUserEntity.Status.ACTIVE); + return userRepository.save(user); + } + + public void deleteAccount(String appKey, String userId) { + userRepository.findByAppKeyAndUserId(appKey, userId) + .ifPresent(userRepository::delete); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushAppSecretClient.java b/push-service/src/main/java/com/xuqm/push/service/PushAppSecretClient.java new file mode 100644 index 0000000..bf0c41e --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/PushAppSecretClient.java @@ -0,0 +1,59 @@ +package com.xuqm.push.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 PushAppSecretClient { + + private final RestTemplate restTemplate = new RestTemplate(); + private final Map cache = new ConcurrentHashMap<>(); + + @Value("${push.tenant-service-url:http://127.0.0.1:8081}") + private String tenantServiceUrl; + + @Value("${push.internal-token:xuqm-internal-token}") + private String internalToken; + + public String getAppSecret(String appKey) { + return cache.computeIfAbsent(appKey, this::fetchAppSecret); + } + + private String fetchAppSecret(String appKey) { + String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) + .path("/api/internal/sdk/apps/{appKey}/secret") + .buildAndExpand(appKey) + .toUriString(); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + try { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + JsonNode.class + ); + JsonNode body = response.getBody(); + if (response.getStatusCode().is2xxSuccessful() + && body != null + && body.path("code").asInt() == 200) { + return body.path("data").path("appSecret").asText(null); + } + } catch (RestClientException e) { + throw new BusinessException(502, "Failed to resolve app secret: " + e.getMessage()); + } + throw new BusinessException(502, "Failed to resolve app secret for appKey: " + appKey); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index f880731..2b84dca 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -3,10 +3,13 @@ package com.xuqm.tenant.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.service.AppService; +import com.xuqm.tenant.service.AppUserClient; import com.xuqm.tenant.service.EmailService; +import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.OperationLogService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; @@ -32,14 +35,20 @@ public class AppController { private final EmailService emailService; private final OperationLogService operationLogService; private final TenantRepository tenantRepository; + private final FeatureServiceManager featureServiceManager; + private final AppUserClient appUserClient; public AppController(AppService appService, EmailService emailService, OperationLogService operationLogService, - TenantRepository tenantRepository) { + TenantRepository tenantRepository, + FeatureServiceManager featureServiceManager, + AppUserClient appUserClient) { this.appService = appService; this.emailService = emailService; this.operationLogService = operationLogService; this.tenantRepository = tenantRepository; + this.featureServiceManager = featureServiceManager; + this.appUserClient = appUserClient; } @GetMapping @@ -118,4 +127,31 @@ public class AppController { String newSecret = appService.resetSecret(appKey, tenantId); return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", newSecret))); } + + /** + * List users of an app. Queries IM accounts when IM is enabled, Push accounts otherwise. + * The ?source=IM|PUSH param overrides auto-detection. + */ + @GetMapping("/{appKey}/users") + public ResponseEntity>> listUsers( + @PathVariable String appKey, + @RequestParam(required = false, defaultValue = "") String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) String source, + @AuthenticationPrincipal String tenantId) { + appService.getByAppKey(appKey, tenantId); + List services = featureServiceManager.listByApp(appKey); + boolean imEnabled = services.stream().anyMatch(s -> + s.getServiceType() == FeatureServiceEntity.ServiceType.IM && s.isEnabled()); + boolean pushEnabled = services.stream().anyMatch(s -> + s.getServiceType() == FeatureServiceEntity.ServiceType.PUSH && s.isEnabled()); + String effectiveSource = (source != null && !source.isBlank()) + ? source.trim().toUpperCase() + : (imEnabled ? "IM" : "PUSH"); + Map result = "PUSH".equals(effectiveSource) + ? appUserClient.listPushUsers(appKey, keyword, page, size) + : appUserClient.listImUsers(appKey, keyword, page, size); + return ResponseEntity.ok(ApiResponse.success(result)); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index c2de691..6bd3b15 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -113,6 +113,7 @@ public class FeatureServiceController { appKey, platform, req == null ? null : req.pushConfig()); + case FILE -> featureServiceManager.buildFileConfig(appKey, platform); }; FeatureServiceEntity saved = featureServiceManager.updateConfig( appKey, platform, serviceType, config); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java index a42086a..472272e 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/FeatureServiceEntity.java @@ -20,7 +20,7 @@ public class FeatureServiceEntity { private static final SecureRandom RANDOM = new SecureRandom(); public enum Platform { ANDROID, IOS, HARMONY } - public enum ServiceType { IM, PUSH, UPDATE } + public enum ServiceType { IM, PUSH, UPDATE, FILE } @Id private String id; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java index 192f98b..f2bad27 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/OpsAdminEntity.java @@ -22,6 +22,12 @@ public class OpsAdminEntity { @Column(nullable = false) private LocalDateTime createdAt; + @Column(unique = true, length = 128) + private String email; + + @Column + private LocalDateTime lastLoginAt; + public String getId() { return id; } public void setId(String id) { this.id = id; } @@ -33,4 +39,10 @@ public class OpsAdminEntity { public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + + public LocalDateTime getLastLoginAt() { return lastLoginAt; } + public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index e3dec3e..ccab18b 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -3,7 +3,9 @@ package com.xuqm.tenant.service; import com.xuqm.common.exception.BusinessException; import com.xuqm.tenant.dto.CreateAppRequest; import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; import org.springframework.stereotype.Service; import java.security.SecureRandom; @@ -19,11 +21,15 @@ public class AppService { private final AppRepository appRepository; private final OperationLogService operationLogService; + private final FeatureServiceRepository featureServiceRepository; private static final SecureRandom random = new SecureRandom(); - public AppService(AppRepository appRepository, OperationLogService operationLogService) { + public AppService(AppRepository appRepository, + OperationLogService operationLogService, + FeatureServiceRepository featureServiceRepository) { this.appRepository = appRepository; this.operationLogService = operationLogService; + this.featureServiceRepository = featureServiceRepository; } public List listByTenant(String tenantId) { @@ -56,6 +62,7 @@ public class AppService { app.setAppSecret(generateSecret()); app.setCreatedAt(LocalDateTime.now()); AppEntity saved = appRepository.save(app); + autoEnableFileService(saved.getAppKey()); operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of( "name", saved.getName(), "packageName", saved.getPackageName(), @@ -112,6 +119,19 @@ public class AppService { return newSecret; } + private void autoEnableFileService(String appKey) { + for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) { + FeatureServiceEntity entity = new FeatureServiceEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppKey(appKey); + entity.setPlatform(platform); + entity.setServiceType(FeatureServiceEntity.ServiceType.FILE); + entity.setEnabled(true); + entity.setCreatedAt(LocalDateTime.now()); + featureServiceRepository.save(entity); + } + } + private String generateAppKey() { return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppUserClient.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppUserClient.java new file mode 100644 index 0000000..662ae22 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppUserClient.java @@ -0,0 +1,72 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Map; + +@Service +public class AppUserClient { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper; + + @Value("${ops.im-service-base-url:http://im-service:8082}") + private String imServiceBaseUrl; + + @Value("${ops.push-service-base-url:http://push-service:8083}") + private String pushServiceBaseUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + public AppUserClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public Map listImUsers(String appKey, String keyword, int page, int size) { + String uri = UriComponentsBuilder.fromHttpUrl(imServiceBaseUrl + "/api/im/internal/presence/accounts") + .queryParam("appKey", appKey) + .queryParam("keyword", keyword == null ? "" : keyword) + .queryParam("page", page) + .queryParam("size", size) + .build().toUriString(); + return dataMap(exchange(uri)); + } + + public Map listPushUsers(String appKey, String keyword, int page, int size) { + String uri = UriComponentsBuilder.fromHttpUrl(pushServiceBaseUrl + "/api/push/internal/accounts") + .queryParam("appKey", appKey) + .queryParam("keyword", keyword == null ? "" : keyword) + .queryParam("page", page) + .queryParam("size", size) + .build().toUriString(); + return dataMap(exchange(uri)); + } + + private String exchange(String uri) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + return restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody(); + } + + private Map dataMap(String body) { + try { + JsonNode data = objectMapper.readTree(body).path("data"); + if (data.isMissingNode() || data.isNull()) { + return Map.of(); + } + return objectMapper.convertValue(data, new TypeReference<>() {}); + } catch (Exception e) { + return Map.of(); + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index fce7f2a..57d1176 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -47,7 +47,8 @@ public class FeatureServiceManager { for (FeatureServiceEntity.ServiceType serviceType : List.of( FeatureServiceEntity.ServiceType.IM, FeatureServiceEntity.ServiceType.PUSH, - FeatureServiceEntity.ServiceType.UPDATE)) { + FeatureServiceEntity.ServiceType.UPDATE, + FeatureServiceEntity.ServiceType.FILE)) { services.stream() .filter(service -> service.getServiceType() == serviceType) .findFirst() @@ -475,6 +476,17 @@ public class FeatureServiceManager { return node.toString(); } + public String buildFileConfig(String appKey, FeatureServiceEntity.Platform platform) { + ObjectNode node = readConfigNode(appKey, platform, FeatureServiceEntity.ServiceType.FILE).deepCopy(); + if (!node.has("maxFileSizeMb")) { + node.put("maxFileSizeMb", 100); + } + if (!node.has("allowedTypes")) { + node.putArray("allowedTypes"); + } + return node.toString(); + } + public String buildPushConfig(String appKey, FeatureServiceEntity.Platform platform, JsonNode pushConfig) { @@ -539,7 +551,8 @@ public class FeatureServiceManager { private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) { return serviceType == FeatureServiceEntity.ServiceType.IM || serviceType == FeatureServiceEntity.ServiceType.PUSH - || serviceType == FeatureServiceEntity.ServiceType.UPDATE; + || serviceType == FeatureServiceEntity.ServiceType.UPDATE + || serviceType == FeatureServiceEntity.ServiceType.FILE; } private JsonNode readConfigNode(String appKey, diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 556cbec..c684556 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -20,6 +20,7 @@ import java.util.UUID; import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.PublishConfigService; import com.xuqm.update.service.AppStoreService; +import com.xuqm.update.service.ImPushUserClient; @RestController @RequestMapping("/api/v1/updates") @@ -33,16 +34,20 @@ public class AppVersionController { private final AppStoreService appStoreService; private final UpdateOperationLogService operationLogService; + private final ImPushUserClient imPushUserClient; + public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, AppStoreService appStoreService, - UpdateOperationLogService operationLogService) { + UpdateOperationLogService operationLogService, + ImPushUserClient imPushUserClient) { this.versionRepository = versionRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.appStoreService = appStoreService; this.operationLogService = operationLogService; + this.imPushUserClient = imPushUserClient; } @GetMapping("/app/check") @@ -72,14 +77,7 @@ public class AppVersionController { // Gray release filtering if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) { - boolean inGray = false; - if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) { - inGray = v.getGrayMemberIds().contains(userId); - } else { - // PERCENT mode: deterministic hash-based sampling - int hash = Math.abs(userId.hashCode()) % 100; - inGray = hash < v.getGrayPercent(); - } + boolean inGray = isInGrayRelease(v, userId); if (!inGray) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); } @@ -159,7 +157,7 @@ public class AppVersionController { entity.setCreatedAt(LocalDateTime.now()); entity.setStoreSubmitMode("MANUAL"); entity.setStoreSubmitScheduledAt(null); - entity.setGrayMode("PERCENT"); + entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { try { @@ -242,8 +240,9 @@ public class AppVersionController { } entity.setGrayEnabled(false); entity.setGrayPercent(0); - entity.setGrayMode("PERCENT"); + entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); AppVersionEntity saved = versionRepository.save(entity); operationLogService.record( saved.getAppKey(), @@ -294,26 +293,45 @@ public class AppVersionController { throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布"); } boolean enabled = Boolean.TRUE.equals(body.get("enabled")); - String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase(); entity.setGrayEnabled(enabled); if (!enabled) { + entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT); entity.setGrayPercent(0); - entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); - } else if ("MEMBERS".equals(grayMode)) { - List memberIds = extractMemberIds(body.get("memberIds")); - String selectionSource = body.get("selectionSource") == null ? "LOCAL" - : body.get("selectionSource").toString().trim().toUpperCase(); - if (memberIds.isEmpty() && "CALLBACK".equals(selectionSource)) { - memberIds = publishConfigService.resolveGrayMembers(entity.getAppKey(), body); - } - entity.setGrayMode("MEMBERS"); - entity.setGrayMemberIds(toJson(memberIds)); - entity.setGrayPercent(0); + entity.setGrayCallbackUrl(null); } else { - entity.setGrayMode("PERCENT"); - entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); - entity.setGrayMemberIds(null); + AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); + entity.setGrayMode(grayMode); + switch (grayMode) { + case PERCENT -> { + entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); + } + case IM_PUSH_USERS -> { + entity.setGrayPercent(0); + entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); + } + case CUSTOMER_SYNC -> { + List memberIds = extractMemberIds(body.get("memberIds")); + if (memberIds.isEmpty()) { + memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); + } + entity.setGrayMemberIds(toJson(memberIds)); + entity.setGrayPercent(0); + entity.setGrayCallbackUrl(null); + } + case CUSTOMER_CALLBACK -> { + String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode"); + } + entity.setGrayCallbackUrl(callbackUrl); + entity.setGrayMemberIds(null); + entity.setGrayPercent(0); + } + } } entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); AppVersionEntity saved = versionRepository.save(entity); @@ -325,13 +343,47 @@ public class AppVersionController { null, Map.of( "enabled", enabled, - "grayMode", saved.getGrayMode(), + "grayMode", saved.getGrayMode().name(), "grayPercent", saved.getGrayPercent(), "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() )); return ResponseEntity.ok(ApiResponse.success(saved)); } + private AppVersionEntity.GrayMode parseGrayMode(Object raw) { + if (raw == null) { + return AppVersionEntity.GrayMode.PERCENT; + } + try { + return AppVersionEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return AppVersionEntity.GrayMode.PERCENT; + } + } + + private boolean isInGrayRelease(AppVersionEntity v, String userId) { + return switch (v.getGrayMode()) { + case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent(); + case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId); + case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId); + case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId); + }; + } + + private boolean resolveCallbackGray(AppVersionEntity v, String userId) { + if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) { + return false; + } + try { + List memberIds = publishConfigService.resolveGrayMembersFromUrl( + v.getGrayCallbackUrl(), v.getAppKey(), userId); + return memberIds.contains(userId); + } catch (Exception e) { + log.warn("Gray callback failed for appKey={} versionId={}: {}", v.getAppKey(), v.getId(), e.getMessage()); + return false; + } + } + @GetMapping("/app/list") public ResponseEntity>> list( @RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) { diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index d0164d5..587df63 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -17,15 +17,21 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; import com.xuqm.update.service.UpdateAssetService; +import com.xuqm.update.service.ImPushUserClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @RestController @RequestMapping("/api/v1/rn") public class RnBundleController { + private static final Logger log = LoggerFactory.getLogger(RnBundleController.class); + private final RnBundleRepository bundleRepository; private final UpdateAssetService updateAssetService; private final PublishConfigService publishConfigService; private final UpdateOperationLogService operationLogService; + private final ImPushUserClient imPushUserClient; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; @@ -33,11 +39,13 @@ public class RnBundleController { public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, - UpdateOperationLogService operationLogService) { + UpdateOperationLogService operationLogService, + ImPushUserClient imPushUserClient) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.operationLogService = operationLogService; + this.imPushUserClient = imPushUserClient; } @GetMapping("/update/check") @@ -66,14 +74,7 @@ public class RnBundleController { RnBundleEntity b = latest.get(); boolean needsUpdate = !b.getVersion().equals(currentVersion); if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) { - boolean inGray = false; - if ("MEMBERS".equals(b.getGrayMode()) && b.getGrayMemberIds() != null) { - inGray = b.getGrayMemberIds().contains(userId); - } else { - int hash = Math.abs(userId.hashCode()) % 100; - inGray = hash < b.getGrayPercent(); - } - if (!inGray) { + if (!isInGrayRelease(b, userId)) { needsUpdate = false; } } @@ -127,7 +128,7 @@ public class RnBundleController { entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setPublishMode("MANUAL"); entity.setScheduledPublishAt(null); - entity.setGrayMode("PERCENT"); + entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); entity.setCreatedAt(LocalDateTime.now()); RnBundleEntity saved = bundleRepository.save(entity); @@ -190,8 +191,9 @@ public class RnBundleController { } entity.setGrayEnabled(false); entity.setGrayPercent(0); - entity.setGrayMode("PERCENT"); + entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); RnBundleEntity saved = bundleRepository.save(entity); operationLogService.record( saved.getAppKey(), @@ -241,26 +243,45 @@ public class RnBundleController { throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布"); } boolean enabled = Boolean.TRUE.equals(body.get("enabled")); - String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase(); entity.setGrayEnabled(enabled); if (!enabled) { + entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT); entity.setGrayPercent(0); - entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); - } else if ("MEMBERS".equals(grayMode)) { - List memberIds = extractMemberIds(body.get("memberIds")); - String selectionSource = body.get("selectionSource") == null ? "LOCAL" - : body.get("selectionSource").toString().trim().toUpperCase(); - if (memberIds.isEmpty() && "CALLBACK".equals(selectionSource)) { - memberIds = publishConfigService.resolveGrayMembers(entity.getAppKey(), body); - } - entity.setGrayMode("MEMBERS"); - entity.setGrayMemberIds(toJson(memberIds)); - entity.setGrayPercent(0); + entity.setGrayCallbackUrl(null); } else { - entity.setGrayMode("PERCENT"); - entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); - entity.setGrayMemberIds(null); + RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode")); + entity.setGrayMode(grayMode); + switch (grayMode) { + case PERCENT -> { + entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0); + entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); + } + case IM_PUSH_USERS -> { + entity.setGrayPercent(0); + entity.setGrayMemberIds(null); + entity.setGrayCallbackUrl(null); + } + case CUSTOMER_SYNC -> { + List memberIds = extractMemberIds(body.get("memberIds")); + if (memberIds.isEmpty()) { + memberIds = publishConfigService.listSyncedGrayMemberIds(entity.getAppKey()); + } + entity.setGrayMemberIds(toJson(memberIds)); + entity.setGrayPercent(0); + entity.setGrayCallbackUrl(null); + } + case CUSTOMER_CALLBACK -> { + String callbackUrl = body.get("callbackUrl") != null ? body.get("callbackUrl").toString().trim() : null; + if (callbackUrl == null || callbackUrl.isBlank()) { + throw new IllegalArgumentException("callbackUrl is required for CUSTOMER_CALLBACK gray mode"); + } + entity.setGrayCallbackUrl(callbackUrl); + entity.setGrayMemberIds(null); + entity.setGrayPercent(0); + } + } } entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); RnBundleEntity saved = bundleRepository.save(entity); @@ -273,13 +294,47 @@ public class RnBundleController { Map.of( "moduleId", saved.getModuleId(), "version", saved.getVersion(), - "grayMode", saved.getGrayMode(), + "grayMode", saved.getGrayMode().name(), "grayPercent", saved.getGrayPercent(), "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() )); return ResponseEntity.ok(ApiResponse.success(saved)); } + private RnBundleEntity.GrayMode parseGrayMode(Object raw) { + if (raw == null) { + return RnBundleEntity.GrayMode.PERCENT; + } + try { + return RnBundleEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return RnBundleEntity.GrayMode.PERCENT; + } + } + + private boolean isInGrayRelease(RnBundleEntity b, String userId) { + return switch (b.getGrayMode()) { + case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent(); + case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(b.getAppKey(), userId); + case CUSTOMER_SYNC -> b.getGrayMemberIds() != null && b.getGrayMemberIds().contains(userId); + case CUSTOMER_CALLBACK -> resolveRnCallbackGray(b, userId); + }; + } + + private boolean resolveRnCallbackGray(RnBundleEntity b, String userId) { + if (b.getGrayCallbackUrl() == null || b.getGrayCallbackUrl().isBlank()) { + return false; + } + try { + List memberIds = publishConfigService.resolveGrayMembersFromUrl( + b.getGrayCallbackUrl(), b.getAppKey(), userId); + return memberIds.contains(userId); + } catch (Exception e) { + log.warn("RN gray callback failed for appKey={} bundleId={}: {}", b.getAppKey(), b.getId(), e.getMessage()); + return false; + } + } + private String resolvePublicBaseUrl() { String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; String suffix = "/api/v1/updates"; diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index f3889ff..f312a6e 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -16,6 +16,14 @@ public class AppVersionEntity { public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } /** Per-store review state used in storeReviewStatus JSON values. */ public enum StoreReviewState { PENDING, SUBMITTING, UNDER_REVIEW, APPROVED, REJECTED } + /** + * Gray release mode. + * PERCENT: deterministic hash-based percentage of all users. + * IM_PUSH_USERS: only users who have an IM or Push account for this appKey. + * CUSTOMER_SYNC: member list synced from the customer's user server (stored in grayMemberIds). + * CUSTOMER_CALLBACK: callback to customer URL on each update check to resolve eligible members. + */ + public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } @Id private String id; @@ -89,14 +97,18 @@ public class AppVersionEntity { @Column(length = 512) private String webhookUrl; - /** Gray release mode: PERCENT or MEMBERS. */ - @Column(length = 16) - private String grayMode = "PERCENT"; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private GrayMode grayMode = GrayMode.PERCENT; - /** JSON array of gray member userIds. */ + /** JSON array of gray member userIds (used by CUSTOMER_SYNC mode). */ @Column(columnDefinition = "TEXT") private String grayMemberIds; + /** Callback URL for CUSTOMER_CALLBACK gray mode. */ + @Column(length = 512) + private String grayCallbackUrl; + /** App package name / bundle identifier, e.g. com.example.myapp */ @Column(length = 256) private String packageName; @@ -170,9 +182,12 @@ public class AppVersionEntity { public String getWebhookUrl() { return webhookUrl; } public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } - public String getGrayMode() { return grayMode; } - public void setGrayMode(String grayMode) { this.grayMode = grayMode; } + public GrayMode getGrayMode() { return grayMode; } + public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; } public String getGrayMemberIds() { return grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } + + public String getGrayCallbackUrl() { return grayCallbackUrl; } + public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } } diff --git a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java index f2ce77b..08611b2 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/RnBundleEntity.java @@ -14,6 +14,7 @@ public class RnBundleEntity { public enum Platform { ANDROID, IOS, HARMONY } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } + public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK } @Id private String id; @@ -61,12 +62,16 @@ public class RnBundleEntity { @Column(nullable = false) private int grayPercent = 0; - @Column(length = 16) - private String grayMode = "PERCENT"; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private GrayMode grayMode = GrayMode.PERCENT; @Column(columnDefinition = "TEXT") private String grayMemberIds; + @Column(length = 512) + private String grayCallbackUrl; + @Column(nullable = false) private LocalDateTime createdAt; @@ -115,12 +120,15 @@ public class RnBundleEntity { public int getGrayPercent() { return grayPercent; } public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; } - public String getGrayMode() { return grayMode; } - public void setGrayMode(String grayMode) { this.grayMode = grayMode; } + public GrayMode getGrayMode() { return grayMode; } + public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; } public String getGrayMemberIds() { return grayMemberIds; } public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; } + public String getGrayCallbackUrl() { return grayCallbackUrl; } + public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } } diff --git a/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java b/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java new file mode 100644 index 0000000..1441c40 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/ImPushUserClient.java @@ -0,0 +1,73 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.JsonNode; +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.stereotype.Component; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class ImPushUserClient { + + private static final Logger log = LoggerFactory.getLogger(ImPushUserClient.class); + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${sdk.im-service-url:http://127.0.0.1:8082}") + private String imServiceUrl; + + @Value("${sdk.push-service-url:http://127.0.0.1:8083}") + private String pushServiceUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + /** + * Returns true if the userId has an account in either the IM or Push service for the given appKey. + */ + public boolean isImOrPushUser(String appKey, String userId) { + return existsInIm(appKey, userId) || existsInPush(appKey, userId); + } + + private boolean existsInIm(String appKey, String userId) { + String url = UriComponentsBuilder.fromHttpUrl(imServiceUrl) + .path("/api/im/internal/presence/accounts/exists") + .queryParam("appKey", appKey) + .queryParam("userId", userId) + .toUriString(); + return callExists(url); + } + + private boolean existsInPush(String appKey, String userId) { + String url = UriComponentsBuilder.fromHttpUrl(pushServiceUrl) + .path("/api/push/internal/accounts/exists") + .queryParam("appKey", appKey) + .queryParam("userId", userId) + .toUriString(); + return callExists(url); + } + + private boolean callExists(String url) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Internal-Token", internalToken); + try { + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), JsonNode.class); + JsonNode body = response.getBody(); + return response.getStatusCode().is2xxSuccessful() + && body != null + && body.path("code").asInt() == 200 + && body.path("data").path("exists").asBoolean(false); + } catch (RestClientException e) { + log.warn("User existence check failed for url={}: {}", url, e.getMessage()); + return false; + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java index e103376..e9429d0 100644 --- a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java +++ b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java @@ -116,6 +116,14 @@ public class PublishConfigService { .toList(); } + public List listSyncedGrayMemberIds(String appKey) { + return grayMemberRepository.findByAppKeyOrderByGroupNameAscNameAscUserIdAsc(appKey) + .stream() + .map(m -> m.getUserId()) + .filter(id -> id != null && !id.isBlank()) + .toList(); + } + public List syncGrayMembers(String appKey) { JsonNode config = getConfigNode(appKey); String url = config.path("grayDirectorySyncCallbackUrl").asText(""); @@ -146,16 +154,33 @@ public class PublishConfigService { if (url == null || url.isBlank()) { throw new IllegalStateException("graySelectCallbackUrl is not configured"); } + String secret = config.path("graySelectCallbackSecret").asText(""); + return resolveGrayMembersFromUrl(url, appKey, null, secret, requestBody); + } + + public List resolveGrayMembersFromUrl(String url, String appKey, String userId) { Map payload = new LinkedHashMap<>(); - if (requestBody != null) { - payload.putAll(requestBody); - } payload.put("appKey", appKey); + if (userId != null) payload.put("userId", userId); payload.put("timestamp", System.currentTimeMillis()); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - String secret = config.path("graySelectCallbackSecret").asText(""); - if (!secret.isBlank()) { + ResponseEntity response = restTemplate.exchange( + URI.create(url), org.springframework.http.HttpMethod.POST, + new org.springframework.http.HttpEntity<>(payload, headers), String.class); + return extractMemberIds(response.getBody()); + } + + private List resolveGrayMembersFromUrl(String url, String appKey, String userId, + String secret, Map requestBody) { + Map payload = new LinkedHashMap<>(); + if (requestBody != null) payload.putAll(requestBody); + payload.put("appKey", appKey); + if (userId != null) payload.put("userId", userId); + payload.put("timestamp", System.currentTimeMillis()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + if (secret != null && !secret.isBlank()) { headers.set("X-Xuqm-Callback-Secret", secret); } ResponseEntity response = restTemplate.exchange( diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index 037aaf6..df83897 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -59,7 +59,7 @@ public class StoreSubmissionService { private static final String HONOR_API = "https://appmarket-openapi-drcn.cloud.honor.com"; private static final String HONOR_IAM = "https://iam.developer.honor.com"; - private final RestTemplate rest = new RestTemplate(); + private final RestTemplate rest = buildRestTemplate(); private final AppVersionRepository versionRepo; private final AppStoreConfigRepository configRepo; private final AppStoreService storeService; @@ -332,6 +332,11 @@ public class StoreSubmissionService { list = asMapList(body.get("data")); } if (list.isEmpty()) { + // Huawei may return the single entry directly as {key:appName, value:appId} + String directId = firstText(body, "value", "id", "appId", "app_id"); + if (!directId.isBlank()) { + return directId; + } throw new RuntimeException("Huawei: app not found for " + packageName + ", response=" + summarizeMap(body)); } String appId = firstText(list.get(0), "id", "appId", "app_id", "value"); @@ -788,7 +793,11 @@ public class StoreSubmissionService { params.put("pkg_name", requirePackageName(v)); params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode()))); params.put("apk_url", apkUrl.toString()); - params.put("update_desc", v.getChangeLog() == null ? "" : v.getChangeLog()); + String oppoUpdateDesc = v.getChangeLog() == null ? "" : v.getChangeLog(); + if (oppoUpdateDesc.length() < 5) { + oppoUpdateDesc = oppoUpdateDesc + " ".substring(0, 5 - oppoUpdateDesc.length()); + } + params.put("update_desc", oppoUpdateDesc); params.put("online_type", "1"); params.put("second_category_id", appInfo.path("ver_second_category_id").asText("")); params.put("third_category_id", appInfo.path("ver_third_category_id").asText("")); @@ -914,7 +923,7 @@ public class StoreSubmissionService { params.put("target_app_key", "developer"); String data = params.entrySet().stream() .sorted(Map.Entry.comparingByKey()) - .map(entry -> entry.getKey() + "=" + entry.getValue()) + .map(entry -> entry.getKey() + "=" + vivoEncodeValue(entry.getValue())) .reduce((a, b) -> a + "&" + b) .orElse(""); String sign = hmacSha256(data, accessSecret); @@ -934,6 +943,26 @@ public class StoreSubmissionService { } } + private static RestTemplate buildRestTemplate() { + 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 + return new RestTemplate(factory); + } + + private String vivoEncodeValue(String value) { + if (value == null) return ""; + try { + return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } catch (Exception e) { + return value; + } + } + private String hmacSha256(String data, String key) { try { javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");