feat: 厂商应用商店提交功能完善及push用户管理
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 <noreply@anthropic.com>
这个提交包含在:
父节点
340a54623b
当前提交
1a18925034
@ -183,9 +183,6 @@ public class ImAdminController {
|
|||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody(required = false) UserSigRequest req) {
|
@RequestBody(required = false) UserSigRequest req) {
|
||||||
ImAccountEntity account = accountService.getAccount(appKey, userId);
|
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);
|
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 userBuf = req == null ? "" : (req.userBuf() == null ? "" : req.userBuf());
|
||||||
String userSig = accountService.generateUserSigToken(appKey, userId, expireSeconds, userBuf);
|
String userSig = accountService.generateUserSigToken(appKey, userId, expireSeconds, userBuf);
|
||||||
@ -208,10 +205,6 @@ public class ImAdminController {
|
|||||||
@PathVariable String userId,
|
@PathVariable String userId,
|
||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@RequestBody UserSigVerifyRequest req) {
|
@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());
|
UserSigUtil.UserSigClaims claims = accountService.verifyUserSig(appKey, userId, req.userSig());
|
||||||
operationLogService.record(appKey, operatorId, "VERIFY_USERSIG", "ACCOUNT", userId, "ok");
|
operationLogService.record(appKey, operatorId, "VERIFY_USERSIG", "ACCOUNT", userId, "ok");
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.im.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
|
import com.xuqm.im.service.ImAccountService;
|
||||||
import com.xuqm.im.service.UserPresenceService;
|
import com.xuqm.im.service.UserPresenceService;
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/im/internal/presence")
|
@RequestMapping("/api/im/internal/presence")
|
||||||
public class InternalPresenceController {
|
public class InternalPresenceController {
|
||||||
|
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
private final UserPresenceService presenceService;
|
private final UserPresenceService presenceService;
|
||||||
|
private final ImAccountService accountService;
|
||||||
|
|
||||||
@Value("${im.internal-token:xuqm-internal-token}")
|
@Value("${im.internal-token:xuqm-internal-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
public InternalPresenceController(JwtUtil jwtUtil, UserPresenceService presenceService) {
|
public InternalPresenceController(JwtUtil jwtUtil,
|
||||||
|
UserPresenceService presenceService,
|
||||||
|
ImAccountService accountService) {
|
||||||
this.jwtUtil = jwtUtil;
|
this.jwtUtil = jwtUtil;
|
||||||
this.presenceService = presenceService;
|
this.presenceService = presenceService;
|
||||||
|
this.accountService = accountService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/resolve-token")
|
@PostMapping("/resolve-token")
|
||||||
@ -63,6 +70,36 @@ public class InternalPresenceController {
|
|||||||
return new PresenceStatus(appKey, userId, online, presenceService.lastSeenAt(userId));
|
return new PresenceStatus(appKey, userId, online, presenceService.lastSeenAt(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/accounts")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<com.xuqm.im.entity.ImAccountEntity> 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<ApiResponse<Map<String, Boolean>>> 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) {
|
private boolean isAllowed(String token) {
|
||||||
return token != null && internalToken.equals(token);
|
return token != null && internalToken.equals(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,4 +18,11 @@ public interface ImAccountRepository extends JpaRepository<ImAccountEntity, Stri
|
|||||||
@Query("SELECT a FROM ImAccountEntity a WHERE a.appKey = :appKey AND a.status = 'ACTIVE' AND " +
|
@Query("SELECT a FROM ImAccountEntity a WHERE a.appKey = :appKey AND a.status = 'ACTIVE' AND " +
|
||||||
"(LOWER(a.userId) LIKE LOWER(CONCAT('%',:kw,'%')) OR LOWER(a.nickname) LIKE LOWER(CONCAT('%',:kw,'%')))")
|
"(LOWER(a.userId) LIKE LOWER(CONCAT('%',:kw,'%')) OR LOWER(a.nickname) LIKE LOWER(CONCAT('%',:kw,'%')))")
|
||||||
List<ImAccountEntity> searchByKeyword(@Param("appKey") String appKey, @Param("kw") String keyword, Pageable pageable);
|
List<ImAccountEntity> 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<ImAccountEntity> findByAppKeyAndKeyword(@Param("appKey") String appKey,
|
||||||
|
@Param("kw") String keyword,
|
||||||
|
Pageable pageable);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,23 @@ public class ImAccountService {
|
|||||||
|
|
||||||
public LoginResult loginWithUserSig(String appKey, String userId, String userSig) {
|
public LoginResult loginWithUserSig(String appKey, String userId, String userSig) {
|
||||||
UserSigUtil.verify(appSecretClient.getAppSecret(appKey), appKey, userId, 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) {
|
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)));
|
return accountRepository.searchByKeyword(appKey, keyword, PageRequest.of(0, Math.max(size, 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public org.springframework.data.domain.Page<ImAccountEntity> 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(
|
public record ImportAccountRequest(
|
||||||
String userId,
|
String userId,
|
||||||
String nickname,
|
String nickname,
|
||||||
|
|||||||
@ -27,7 +27,7 @@ public class SecurityConfig {
|
|||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.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()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.push.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.push.entity.DeviceLoginLogEntity;
|
import com.xuqm.push.entity.DeviceLoginLogEntity;
|
||||||
|
import com.xuqm.push.service.PushAccountService;
|
||||||
import com.xuqm.push.service.PushDiagnosticsService;
|
import com.xuqm.push.service.PushDiagnosticsService;
|
||||||
import com.xuqm.push.service.PushDispatcher;
|
import com.xuqm.push.service.PushDispatcher;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/push/internal")
|
@RequestMapping("/api/push/internal")
|
||||||
@ -23,13 +26,17 @@ public class InternalPushController {
|
|||||||
|
|
||||||
private final PushDispatcher pushDispatcher;
|
private final PushDispatcher pushDispatcher;
|
||||||
private final PushDiagnosticsService diagnosticsService;
|
private final PushDiagnosticsService diagnosticsService;
|
||||||
|
private final PushAccountService accountService;
|
||||||
|
|
||||||
@Value("${push.internal-token:xuqm-internal-token}")
|
@Value("${push.internal-token:xuqm-internal-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) {
|
public InternalPushController(PushDispatcher pushDispatcher,
|
||||||
|
PushDiagnosticsService diagnosticsService,
|
||||||
|
PushAccountService accountService) {
|
||||||
this.pushDispatcher = pushDispatcher;
|
this.pushDispatcher = pushDispatcher;
|
||||||
this.diagnosticsService = diagnosticsService;
|
this.diagnosticsService = diagnosticsService;
|
||||||
|
this.accountService = accountService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/notify")
|
@PostMapping("/notify")
|
||||||
@ -88,6 +95,36 @@ public class InternalPushController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(result));
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/accounts")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> 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<com.xuqm.push.entity.PushUserEntity> 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<ApiResponse<Map<String, Boolean>>> 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) {
|
private boolean isAllowed(String token) {
|
||||||
return token != null && internalToken.equals(token);
|
return token != null && internalToken.equals(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<ApiResponse<Map<String, Object>>> 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<String, Object> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,17 +2,23 @@ package com.xuqm.push.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.push.entity.DeviceLoginLogEntity;
|
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 com.xuqm.push.service.PushDiagnosticsService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
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.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -21,11 +27,88 @@ import java.util.Map;
|
|||||||
public class PushManagementController {
|
public class PushManagementController {
|
||||||
|
|
||||||
private final PushDiagnosticsService diagnosticsService;
|
private final PushDiagnosticsService diagnosticsService;
|
||||||
|
private final PushAccountService accountService;
|
||||||
|
|
||||||
public PushManagementController(PushDiagnosticsService diagnosticsService) {
|
public PushManagementController(PushDiagnosticsService diagnosticsService,
|
||||||
|
PushAccountService accountService) {
|
||||||
this.diagnosticsService = diagnosticsService;
|
this.diagnosticsService = diagnosticsService;
|
||||||
|
this.accountService = accountService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- user account management ----
|
||||||
|
|
||||||
|
@GetMapping("/users")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||||
|
@RequestParam String appKey,
|
||||||
|
@RequestParam(required = false, defaultValue = "") String keyword,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
Page<PushUserEntity> 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<ApiResponse<PushUserEntity>> getUser(
|
||||||
|
@PathVariable String userId,
|
||||||
|
@RequestParam String appKey) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(accountService.getAccount(appKey, userId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/users/{userId}")
|
||||||
|
public ResponseEntity<ApiResponse<PushUserEntity>> 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<ApiResponse<PushUserEntity>> 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<ApiResponse<Void>> deleteUser(
|
||||||
|
@PathVariable String userId,
|
||||||
|
@RequestParam String appKey) {
|
||||||
|
accountService.deleteAccount(appKey, userId);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/users/import")
|
||||||
|
public ResponseEntity<ApiResponse<PushUserEntity>> 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")
|
@GetMapping("/user-status")
|
||||||
public ResponseEntity<ApiResponse<PushDiagnosticsService.PushTokenDiagnostics>> userStatus(
|
public ResponseEntity<ApiResponse<PushDiagnosticsService.PushTokenDiagnostics>> userStatus(
|
||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@ -59,11 +142,10 @@ public class PushManagementController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(result));
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TestOfflineRequest(
|
public record UpdateUserRequest(String appKey, String nickname, String avatar, String gender) {}
|
||||||
String appKey,
|
public record UserStatusRequest(String appKey, String status) {}
|
||||||
String userId,
|
public record ImportUserRequest(String appKey, String userId, String nickname,
|
||||||
String title,
|
String avatar, String gender, String status) {}
|
||||||
String body,
|
public record TestOfflineRequest(String appKey, String userId,
|
||||||
String payload
|
String title, String body, String payload) {}
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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<PushUserEntity, String> {
|
||||||
|
|
||||||
|
Optional<PushUserEntity> findByAppKeyAndUserId(String appKey, String userId);
|
||||||
|
|
||||||
|
boolean existsByAppKeyAndUserId(String appKey, String userId);
|
||||||
|
|
||||||
|
Page<PushUserEntity> 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<PushUserEntity> findByAppKeyAndKeyword(@Param("appKey") String appKey,
|
||||||
|
@Param("kw") String keyword,
|
||||||
|
Pageable pageable);
|
||||||
|
}
|
||||||
@ -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<PushUserEntity> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, String> 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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,13 @@ package com.xuqm.tenant.controller;
|
|||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
import com.xuqm.tenant.entity.TenantEntity;
|
import com.xuqm.tenant.entity.TenantEntity;
|
||||||
import com.xuqm.tenant.repository.TenantRepository;
|
import com.xuqm.tenant.repository.TenantRepository;
|
||||||
import com.xuqm.tenant.service.AppService;
|
import com.xuqm.tenant.service.AppService;
|
||||||
|
import com.xuqm.tenant.service.AppUserClient;
|
||||||
import com.xuqm.tenant.service.EmailService;
|
import com.xuqm.tenant.service.EmailService;
|
||||||
|
import com.xuqm.tenant.service.FeatureServiceManager;
|
||||||
import com.xuqm.tenant.service.OperationLogService;
|
import com.xuqm.tenant.service.OperationLogService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -32,14 +35,20 @@ public class AppController {
|
|||||||
private final EmailService emailService;
|
private final EmailService emailService;
|
||||||
private final OperationLogService operationLogService;
|
private final OperationLogService operationLogService;
|
||||||
private final TenantRepository tenantRepository;
|
private final TenantRepository tenantRepository;
|
||||||
|
private final FeatureServiceManager featureServiceManager;
|
||||||
|
private final AppUserClient appUserClient;
|
||||||
|
|
||||||
public AppController(AppService appService, EmailService emailService,
|
public AppController(AppService appService, EmailService emailService,
|
||||||
OperationLogService operationLogService,
|
OperationLogService operationLogService,
|
||||||
TenantRepository tenantRepository) {
|
TenantRepository tenantRepository,
|
||||||
|
FeatureServiceManager featureServiceManager,
|
||||||
|
AppUserClient appUserClient) {
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.emailService = emailService;
|
this.emailService = emailService;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
|
this.featureServiceManager = featureServiceManager;
|
||||||
|
this.appUserClient = appUserClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@ -118,4 +127,31 @@ public class AppController {
|
|||||||
String newSecret = appService.resetSecret(appKey, tenantId);
|
String newSecret = appService.resetSecret(appKey, tenantId);
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", newSecret)));
|
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<ApiResponse<Map<String, Object>>> 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<FeatureServiceEntity> 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<String, Object> result = "PUSH".equals(effectiveSource)
|
||||||
|
? appUserClient.listPushUsers(appKey, keyword, page, size)
|
||||||
|
: appUserClient.listImUsers(appKey, keyword, page, size);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,6 +113,7 @@ public class FeatureServiceController {
|
|||||||
appKey,
|
appKey,
|
||||||
platform,
|
platform,
|
||||||
req == null ? null : req.pushConfig());
|
req == null ? null : req.pushConfig());
|
||||||
|
case FILE -> featureServiceManager.buildFileConfig(appKey, platform);
|
||||||
};
|
};
|
||||||
FeatureServiceEntity saved = featureServiceManager.updateConfig(
|
FeatureServiceEntity saved = featureServiceManager.updateConfig(
|
||||||
appKey, platform, serviceType, config);
|
appKey, platform, serviceType, config);
|
||||||
|
|||||||
@ -20,7 +20,7 @@ public class FeatureServiceEntity {
|
|||||||
private static final SecureRandom RANDOM = new SecureRandom();
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
public enum Platform { ANDROID, IOS, HARMONY }
|
public enum Platform { ANDROID, IOS, HARMONY }
|
||||||
public enum ServiceType { IM, PUSH, UPDATE }
|
public enum ServiceType { IM, PUSH, UPDATE, FILE }
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
|
|||||||
@ -22,6 +22,12 @@ public class OpsAdminEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(unique = true, length = 128)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
public String getId() { return id; }
|
public String getId() { return id; }
|
||||||
public void setId(String id) { this.id = id; }
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
@ -33,4 +39,10 @@ public class OpsAdminEntity {
|
|||||||
|
|
||||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package com.xuqm.tenant.service;
|
|||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
import com.xuqm.tenant.repository.AppRepository;
|
import com.xuqm.tenant.repository.AppRepository;
|
||||||
|
import com.xuqm.tenant.repository.FeatureServiceRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
@ -19,11 +21,15 @@ public class AppService {
|
|||||||
|
|
||||||
private final AppRepository appRepository;
|
private final AppRepository appRepository;
|
||||||
private final OperationLogService operationLogService;
|
private final OperationLogService operationLogService;
|
||||||
|
private final FeatureServiceRepository featureServiceRepository;
|
||||||
private static final SecureRandom random = new SecureRandom();
|
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.appRepository = appRepository;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
|
this.featureServiceRepository = featureServiceRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<AppEntity> listByTenant(String tenantId) {
|
public List<AppEntity> listByTenant(String tenantId) {
|
||||||
@ -56,6 +62,7 @@ public class AppService {
|
|||||||
app.setAppSecret(generateSecret());
|
app.setAppSecret(generateSecret());
|
||||||
app.setCreatedAt(LocalDateTime.now());
|
app.setCreatedAt(LocalDateTime.now());
|
||||||
AppEntity saved = appRepository.save(app);
|
AppEntity saved = appRepository.save(app);
|
||||||
|
autoEnableFileService(saved.getAppKey());
|
||||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
|
||||||
"name", saved.getName(),
|
"name", saved.getName(),
|
||||||
"packageName", saved.getPackageName(),
|
"packageName", saved.getPackageName(),
|
||||||
@ -112,6 +119,19 @@ public class AppService {
|
|||||||
return newSecret;
|
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() {
|
private String generateAppKey() {
|
||||||
return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
|
return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<String, Object> 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<String, Object> 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<String, Object> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -47,7 +47,8 @@ public class FeatureServiceManager {
|
|||||||
for (FeatureServiceEntity.ServiceType serviceType : List.of(
|
for (FeatureServiceEntity.ServiceType serviceType : List.of(
|
||||||
FeatureServiceEntity.ServiceType.IM,
|
FeatureServiceEntity.ServiceType.IM,
|
||||||
FeatureServiceEntity.ServiceType.PUSH,
|
FeatureServiceEntity.ServiceType.PUSH,
|
||||||
FeatureServiceEntity.ServiceType.UPDATE)) {
|
FeatureServiceEntity.ServiceType.UPDATE,
|
||||||
|
FeatureServiceEntity.ServiceType.FILE)) {
|
||||||
services.stream()
|
services.stream()
|
||||||
.filter(service -> service.getServiceType() == serviceType)
|
.filter(service -> service.getServiceType() == serviceType)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
@ -475,6 +476,17 @@ public class FeatureServiceManager {
|
|||||||
return node.toString();
|
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,
|
public String buildPushConfig(String appKey,
|
||||||
FeatureServiceEntity.Platform platform,
|
FeatureServiceEntity.Platform platform,
|
||||||
JsonNode pushConfig) {
|
JsonNode pushConfig) {
|
||||||
@ -539,7 +551,8 @@ public class FeatureServiceManager {
|
|||||||
private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) {
|
private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) {
|
||||||
return serviceType == FeatureServiceEntity.ServiceType.IM
|
return serviceType == FeatureServiceEntity.ServiceType.IM
|
||||||
|| serviceType == FeatureServiceEntity.ServiceType.PUSH
|
|| serviceType == FeatureServiceEntity.ServiceType.PUSH
|
||||||
|| serviceType == FeatureServiceEntity.ServiceType.UPDATE;
|
|| serviceType == FeatureServiceEntity.ServiceType.UPDATE
|
||||||
|
|| serviceType == FeatureServiceEntity.ServiceType.FILE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode readConfigNode(String appKey,
|
private JsonNode readConfigNode(String appKey,
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import java.util.UUID;
|
|||||||
import com.xuqm.update.service.UpdateAssetService;
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
import com.xuqm.update.service.PublishConfigService;
|
import com.xuqm.update.service.PublishConfigService;
|
||||||
import com.xuqm.update.service.AppStoreService;
|
import com.xuqm.update.service.AppStoreService;
|
||||||
|
import com.xuqm.update.service.ImPushUserClient;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/updates")
|
@RequestMapping("/api/v1/updates")
|
||||||
@ -33,16 +34,20 @@ public class AppVersionController {
|
|||||||
private final AppStoreService appStoreService;
|
private final AppStoreService appStoreService;
|
||||||
private final UpdateOperationLogService operationLogService;
|
private final UpdateOperationLogService operationLogService;
|
||||||
|
|
||||||
|
private final ImPushUserClient imPushUserClient;
|
||||||
|
|
||||||
public AppVersionController(AppVersionRepository versionRepository,
|
public AppVersionController(AppVersionRepository versionRepository,
|
||||||
UpdateAssetService updateAssetService,
|
UpdateAssetService updateAssetService,
|
||||||
PublishConfigService publishConfigService,
|
PublishConfigService publishConfigService,
|
||||||
AppStoreService appStoreService,
|
AppStoreService appStoreService,
|
||||||
UpdateOperationLogService operationLogService) {
|
UpdateOperationLogService operationLogService,
|
||||||
|
ImPushUserClient imPushUserClient) {
|
||||||
this.versionRepository = versionRepository;
|
this.versionRepository = versionRepository;
|
||||||
this.updateAssetService = updateAssetService;
|
this.updateAssetService = updateAssetService;
|
||||||
this.publishConfigService = publishConfigService;
|
this.publishConfigService = publishConfigService;
|
||||||
this.appStoreService = appStoreService;
|
this.appStoreService = appStoreService;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
|
this.imPushUserClient = imPushUserClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/app/check")
|
@GetMapping("/app/check")
|
||||||
@ -72,14 +77,7 @@ public class AppVersionController {
|
|||||||
|
|
||||||
// Gray release filtering
|
// Gray release filtering
|
||||||
if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
||||||
boolean inGray = false;
|
boolean inGray = isInGrayRelease(v, userId);
|
||||||
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();
|
|
||||||
}
|
|
||||||
if (!inGray) {
|
if (!inGray) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||||
}
|
}
|
||||||
@ -159,7 +157,7 @@ public class AppVersionController {
|
|||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
entity.setStoreSubmitMode("MANUAL");
|
entity.setStoreSubmitMode("MANUAL");
|
||||||
entity.setStoreSubmitScheduledAt(null);
|
entity.setStoreSubmitScheduledAt(null);
|
||||||
entity.setGrayMode("PERCENT");
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
|
||||||
try {
|
try {
|
||||||
@ -242,8 +240,9 @@ public class AppVersionController {
|
|||||||
}
|
}
|
||||||
entity.setGrayEnabled(false);
|
entity.setGrayEnabled(false);
|
||||||
entity.setGrayPercent(0);
|
entity.setGrayPercent(0);
|
||||||
entity.setGrayMode("PERCENT");
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
AppVersionEntity saved = versionRepository.save(entity);
|
AppVersionEntity saved = versionRepository.save(entity);
|
||||||
operationLogService.record(
|
operationLogService.record(
|
||||||
saved.getAppKey(),
|
saved.getAppKey(),
|
||||||
@ -294,26 +293,45 @@ public class AppVersionController {
|
|||||||
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
|
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
|
||||||
}
|
}
|
||||||
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
||||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
|
||||||
entity.setGrayEnabled(enabled);
|
entity.setGrayEnabled(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayPercent(0);
|
entity.setGrayPercent(0);
|
||||||
entity.setGrayMode("PERCENT");
|
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
} else if ("MEMBERS".equals(grayMode)) {
|
entity.setGrayCallbackUrl(null);
|
||||||
List<String> 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);
|
|
||||||
} else {
|
} else {
|
||||||
entity.setGrayMode("PERCENT");
|
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.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
|
}
|
||||||
|
case IM_PUSH_USERS -> {
|
||||||
|
entity.setGrayPercent(0);
|
||||||
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
|
}
|
||||||
|
case CUSTOMER_SYNC -> {
|
||||||
|
List<String> 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);
|
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
|
||||||
AppVersionEntity saved = versionRepository.save(entity);
|
AppVersionEntity saved = versionRepository.save(entity);
|
||||||
@ -325,13 +343,47 @@ public class AppVersionController {
|
|||||||
null,
|
null,
|
||||||
Map.of(
|
Map.of(
|
||||||
"enabled", enabled,
|
"enabled", enabled,
|
||||||
"grayMode", saved.getGrayMode(),
|
"grayMode", saved.getGrayMode().name(),
|
||||||
"grayPercent", saved.getGrayPercent(),
|
"grayPercent", saved.getGrayPercent(),
|
||||||
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
|
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
|
||||||
));
|
));
|
||||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
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<String> 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")
|
@GetMapping("/app/list")
|
||||||
public ResponseEntity<ApiResponse<List<AppVersionEntity>>> list(
|
public ResponseEntity<ApiResponse<List<AppVersionEntity>>> list(
|
||||||
@RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) {
|
@RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) {
|
||||||
|
|||||||
@ -17,15 +17,21 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import com.xuqm.update.service.UpdateAssetService;
|
import com.xuqm.update.service.UpdateAssetService;
|
||||||
|
import com.xuqm.update.service.ImPushUserClient;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/rn")
|
@RequestMapping("/api/v1/rn")
|
||||||
public class RnBundleController {
|
public class RnBundleController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(RnBundleController.class);
|
||||||
|
|
||||||
private final RnBundleRepository bundleRepository;
|
private final RnBundleRepository bundleRepository;
|
||||||
private final UpdateAssetService updateAssetService;
|
private final UpdateAssetService updateAssetService;
|
||||||
private final PublishConfigService publishConfigService;
|
private final PublishConfigService publishConfigService;
|
||||||
private final UpdateOperationLogService operationLogService;
|
private final UpdateOperationLogService operationLogService;
|
||||||
|
private final ImPushUserClient imPushUserClient;
|
||||||
|
|
||||||
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
@ -33,11 +39,13 @@ public class RnBundleController {
|
|||||||
public RnBundleController(RnBundleRepository bundleRepository,
|
public RnBundleController(RnBundleRepository bundleRepository,
|
||||||
UpdateAssetService updateAssetService,
|
UpdateAssetService updateAssetService,
|
||||||
PublishConfigService publishConfigService,
|
PublishConfigService publishConfigService,
|
||||||
UpdateOperationLogService operationLogService) {
|
UpdateOperationLogService operationLogService,
|
||||||
|
ImPushUserClient imPushUserClient) {
|
||||||
this.bundleRepository = bundleRepository;
|
this.bundleRepository = bundleRepository;
|
||||||
this.updateAssetService = updateAssetService;
|
this.updateAssetService = updateAssetService;
|
||||||
this.publishConfigService = publishConfigService;
|
this.publishConfigService = publishConfigService;
|
||||||
this.operationLogService = operationLogService;
|
this.operationLogService = operationLogService;
|
||||||
|
this.imPushUserClient = imPushUserClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/update/check")
|
@GetMapping("/update/check")
|
||||||
@ -66,14 +74,7 @@ public class RnBundleController {
|
|||||||
RnBundleEntity b = latest.get();
|
RnBundleEntity b = latest.get();
|
||||||
boolean needsUpdate = !b.getVersion().equals(currentVersion);
|
boolean needsUpdate = !b.getVersion().equals(currentVersion);
|
||||||
if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) {
|
||||||
boolean inGray = false;
|
if (!isInGrayRelease(b, userId)) {
|
||||||
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) {
|
|
||||||
needsUpdate = false;
|
needsUpdate = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,7 +128,7 @@ public class RnBundleController {
|
|||||||
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
|
||||||
entity.setPublishMode("MANUAL");
|
entity.setPublishMode("MANUAL");
|
||||||
entity.setScheduledPublishAt(null);
|
entity.setScheduledPublishAt(null);
|
||||||
entity.setGrayMode("PERCENT");
|
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
entity.setCreatedAt(LocalDateTime.now());
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
RnBundleEntity saved = bundleRepository.save(entity);
|
RnBundleEntity saved = bundleRepository.save(entity);
|
||||||
@ -190,8 +191,9 @@ public class RnBundleController {
|
|||||||
}
|
}
|
||||||
entity.setGrayEnabled(false);
|
entity.setGrayEnabled(false);
|
||||||
entity.setGrayPercent(0);
|
entity.setGrayPercent(0);
|
||||||
entity.setGrayMode("PERCENT");
|
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
RnBundleEntity saved = bundleRepository.save(entity);
|
RnBundleEntity saved = bundleRepository.save(entity);
|
||||||
operationLogService.record(
|
operationLogService.record(
|
||||||
saved.getAppKey(),
|
saved.getAppKey(),
|
||||||
@ -241,26 +243,45 @@ public class RnBundleController {
|
|||||||
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
|
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
|
||||||
}
|
}
|
||||||
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
|
||||||
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
|
|
||||||
entity.setGrayEnabled(enabled);
|
entity.setGrayEnabled(enabled);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
|
||||||
entity.setGrayPercent(0);
|
entity.setGrayPercent(0);
|
||||||
entity.setGrayMode("PERCENT");
|
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
} else if ("MEMBERS".equals(grayMode)) {
|
entity.setGrayCallbackUrl(null);
|
||||||
List<String> 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);
|
|
||||||
} else {
|
} else {
|
||||||
entity.setGrayMode("PERCENT");
|
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.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
|
||||||
entity.setGrayMemberIds(null);
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
|
}
|
||||||
|
case IM_PUSH_USERS -> {
|
||||||
|
entity.setGrayPercent(0);
|
||||||
|
entity.setGrayMemberIds(null);
|
||||||
|
entity.setGrayCallbackUrl(null);
|
||||||
|
}
|
||||||
|
case CUSTOMER_SYNC -> {
|
||||||
|
List<String> 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);
|
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
|
||||||
RnBundleEntity saved = bundleRepository.save(entity);
|
RnBundleEntity saved = bundleRepository.save(entity);
|
||||||
@ -273,13 +294,47 @@ public class RnBundleController {
|
|||||||
Map.of(
|
Map.of(
|
||||||
"moduleId", saved.getModuleId(),
|
"moduleId", saved.getModuleId(),
|
||||||
"version", saved.getVersion(),
|
"version", saved.getVersion(),
|
||||||
"grayMode", saved.getGrayMode(),
|
"grayMode", saved.getGrayMode().name(),
|
||||||
"grayPercent", saved.getGrayPercent(),
|
"grayPercent", saved.getGrayPercent(),
|
||||||
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
|
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
|
||||||
));
|
));
|
||||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
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<String> 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() {
|
private String resolvePublicBaseUrl() {
|
||||||
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
String suffix = "/api/v1/updates";
|
String suffix = "/api/v1/updates";
|
||||||
|
|||||||
@ -16,6 +16,14 @@ public class AppVersionEntity {
|
|||||||
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 }
|
||||||
|
/**
|
||||||
|
* 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
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
@ -89,14 +97,18 @@ public class AppVersionEntity {
|
|||||||
@Column(length = 512)
|
@Column(length = 512)
|
||||||
private String webhookUrl;
|
private String webhookUrl;
|
||||||
|
|
||||||
/** Gray release mode: PERCENT or MEMBERS. */
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(length = 16)
|
@Column(nullable = false, length = 24)
|
||||||
private String grayMode = "PERCENT";
|
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")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String grayMemberIds;
|
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 */
|
/** App package name / bundle identifier, e.g. com.example.myapp */
|
||||||
@Column(length = 256)
|
@Column(length = 256)
|
||||||
private String packageName;
|
private String packageName;
|
||||||
@ -170,9 +182,12 @@ public class AppVersionEntity {
|
|||||||
public String getWebhookUrl() { return webhookUrl; }
|
public String getWebhookUrl() { return webhookUrl; }
|
||||||
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
|
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
|
||||||
|
|
||||||
public String getGrayMode() { return grayMode; }
|
public GrayMode getGrayMode() { return grayMode; }
|
||||||
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
|
public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; }
|
||||||
|
|
||||||
public String getGrayMemberIds() { return grayMemberIds; }
|
public String getGrayMemberIds() { return grayMemberIds; }
|
||||||
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
|
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
|
||||||
|
|
||||||
|
public String getGrayCallbackUrl() { return grayCallbackUrl; }
|
||||||
|
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ public class RnBundleEntity {
|
|||||||
|
|
||||||
public enum Platform { ANDROID, IOS, HARMONY }
|
public enum Platform { ANDROID, IOS, HARMONY }
|
||||||
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
|
||||||
|
public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK }
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
private String id;
|
private String id;
|
||||||
@ -61,12 +62,16 @@ public class RnBundleEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private int grayPercent = 0;
|
private int grayPercent = 0;
|
||||||
|
|
||||||
@Column(length = 16)
|
@Enumerated(EnumType.STRING)
|
||||||
private String grayMode = "PERCENT";
|
@Column(nullable = false, length = 24)
|
||||||
|
private GrayMode grayMode = GrayMode.PERCENT;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String grayMemberIds;
|
private String grayMemberIds;
|
||||||
|
|
||||||
|
@Column(length = 512)
|
||||||
|
private String grayCallbackUrl;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@ -115,12 +120,15 @@ public class RnBundleEntity {
|
|||||||
public int getGrayPercent() { return grayPercent; }
|
public int getGrayPercent() { return grayPercent; }
|
||||||
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
|
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
|
||||||
|
|
||||||
public String getGrayMode() { return grayMode; }
|
public GrayMode getGrayMode() { return grayMode; }
|
||||||
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
|
public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; }
|
||||||
|
|
||||||
public String getGrayMemberIds() { return grayMemberIds; }
|
public String getGrayMemberIds() { return grayMemberIds; }
|
||||||
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = 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 LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<JsonNode> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -116,6 +116,14 @@ public class PublishConfigService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> listSyncedGrayMemberIds(String appKey) {
|
||||||
|
return grayMemberRepository.findByAppKeyOrderByGroupNameAscNameAscUserIdAsc(appKey)
|
||||||
|
.stream()
|
||||||
|
.map(m -> m.getUserId())
|
||||||
|
.filter(id -> id != null && !id.isBlank())
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
public List<GrayMemberGroupView> syncGrayMembers(String appKey) {
|
public List<GrayMemberGroupView> syncGrayMembers(String appKey) {
|
||||||
JsonNode config = getConfigNode(appKey);
|
JsonNode config = getConfigNode(appKey);
|
||||||
String url = config.path("grayDirectorySyncCallbackUrl").asText("");
|
String url = config.path("grayDirectorySyncCallbackUrl").asText("");
|
||||||
@ -146,16 +154,33 @@ public class PublishConfigService {
|
|||||||
if (url == null || url.isBlank()) {
|
if (url == null || url.isBlank()) {
|
||||||
throw new IllegalStateException("graySelectCallbackUrl is not configured");
|
throw new IllegalStateException("graySelectCallbackUrl is not configured");
|
||||||
}
|
}
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
String secret = config.path("graySelectCallbackSecret").asText("");
|
||||||
if (requestBody != null) {
|
return resolveGrayMembersFromUrl(url, appKey, null, secret, requestBody);
|
||||||
payload.putAll(requestBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> resolveGrayMembersFromUrl(String url, String appKey, String userId) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("appKey", appKey);
|
payload.put("appKey", appKey);
|
||||||
|
if (userId != null) payload.put("userId", userId);
|
||||||
payload.put("timestamp", System.currentTimeMillis());
|
payload.put("timestamp", System.currentTimeMillis());
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
String secret = config.path("graySelectCallbackSecret").asText("");
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
if (!secret.isBlank()) {
|
URI.create(url), org.springframework.http.HttpMethod.POST,
|
||||||
|
new org.springframework.http.HttpEntity<>(payload, headers), String.class);
|
||||||
|
return extractMemberIds(response.getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveGrayMembersFromUrl(String url, String appKey, String userId,
|
||||||
|
String secret, Map<String, Object> requestBody) {
|
||||||
|
Map<String, Object> 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);
|
headers.set("X-Xuqm-Callback-Secret", secret);
|
||||||
}
|
}
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
|
|||||||
@ -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_API = "https://appmarket-openapi-drcn.cloud.honor.com";
|
||||||
private static final String HONOR_IAM = "https://iam.developer.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 AppVersionRepository versionRepo;
|
||||||
private final AppStoreConfigRepository configRepo;
|
private final AppStoreConfigRepository configRepo;
|
||||||
private final AppStoreService storeService;
|
private final AppStoreService storeService;
|
||||||
@ -332,6 +332,11 @@ public class StoreSubmissionService {
|
|||||||
list = asMapList(body.get("data"));
|
list = asMapList(body.get("data"));
|
||||||
}
|
}
|
||||||
if (list.isEmpty()) {
|
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));
|
throw new RuntimeException("Huawei: app not found for " + packageName + ", response=" + summarizeMap(body));
|
||||||
}
|
}
|
||||||
String appId = firstText(list.get(0), "id", "appId", "app_id", "value");
|
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("pkg_name", requirePackageName(v));
|
||||||
params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode())));
|
params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode())));
|
||||||
params.put("apk_url", apkUrl.toString());
|
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("online_type", "1");
|
||||||
params.put("second_category_id", appInfo.path("ver_second_category_id").asText(""));
|
params.put("second_category_id", appInfo.path("ver_second_category_id").asText(""));
|
||||||
params.put("third_category_id", appInfo.path("ver_third_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");
|
params.put("target_app_key", "developer");
|
||||||
String data = params.entrySet().stream()
|
String data = params.entrySet().stream()
|
||||||
.sorted(Map.Entry.comparingByKey())
|
.sorted(Map.Entry.comparingByKey())
|
||||||
.map(entry -> entry.getKey() + "=" + entry.getValue())
|
.map(entry -> entry.getKey() + "=" + vivoEncodeValue(entry.getValue()))
|
||||||
.reduce((a, b) -> a + "&" + b)
|
.reduce((a, b) -> a + "&" + b)
|
||||||
.orElse("");
|
.orElse("");
|
||||||
String sign = hmacSha256(data, accessSecret);
|
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) {
|
private String hmacSha256(String data, String key) {
|
||||||
try {
|
try {
|
||||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户