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>
这个提交包含在:
XuqmGroup 2026-05-14 23:40:35 +08:00
父节点 340a54623b
当前提交 1a18925034
共有 26 个文件被更改,包括 1011 次插入96 次删除

查看文件

@ -183,9 +183,6 @@ public class ImAdminController {
@AuthenticationPrincipal String operatorId,
@RequestBody(required = false) UserSigRequest req) {
ImAccountEntity account = accountService.getAccount(appKey, userId);
if (!account.isAdmin()) {
throw new BusinessException(403, "Only admin accounts can generate UserSig for service-side usage");
}
long expireSeconds = req == null || req.expireSeconds() == null ? 180L * 24 * 60 * 60 : Math.max(req.expireSeconds(), 60L);
String userBuf = req == null ? "" : (req.userBuf() == null ? "" : req.userBuf());
String userSig = accountService.generateUserSigToken(appKey, userId, expireSeconds, userBuf);
@ -208,10 +205,6 @@ public class ImAdminController {
@PathVariable String userId,
@AuthenticationPrincipal String operatorId,
@RequestBody UserSigVerifyRequest req) {
ImAccountEntity account = accountService.getAccount(appKey, userId);
if (!account.isAdmin()) {
throw new BusinessException(403, "Only admin accounts can verify service-side UserSig");
}
UserSigUtil.UserSigClaims claims = accountService.verifyUserSig(appKey, userId, req.userSig());
operationLogService.record(appKey, operatorId, "VERIFY_USERSIG", "ACCOUNT", userId, "ok");
return ResponseEntity.ok(ApiResponse.success(Map.of(

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.service.ImAccountService;
import com.xuqm.im.service.UserPresenceService;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Value;
@ -15,19 +16,25 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/im/internal/presence")
public class InternalPresenceController {
private final JwtUtil jwtUtil;
private final UserPresenceService presenceService;
private final ImAccountService accountService;
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalPresenceController(JwtUtil jwtUtil, UserPresenceService presenceService) {
public InternalPresenceController(JwtUtil jwtUtil,
UserPresenceService presenceService,
ImAccountService accountService) {
this.jwtUtil = jwtUtil;
this.presenceService = presenceService;
this.accountService = accountService;
}
@PostMapping("/resolve-token")
@ -63,6 +70,36 @@ public class InternalPresenceController {
return new PresenceStatus(appKey, userId, online, presenceService.lastSeenAt(userId));
}
@GetMapping("/accounts")
public ResponseEntity<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) {
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 " +
"(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);
@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) {
UserSigUtil.verify(appSecretClient.getAppSecret(appKey), appKey, userId, userSig);
return login(appKey, userId);
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
.orElseGet(() -> {
ImAccountEntity entity = new ImAccountEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
entity.setUserId(userId);
entity.setNickname(userId);
entity.setGender(ImAccountEntity.Gender.UNKNOWN);
entity.setStatus(ImAccountEntity.Status.ACTIVE);
entity.setCreatedAt(LocalDateTime.now());
return accountRepository.save(entity);
});
if (account.getStatus() == ImAccountEntity.Status.BANNED) {
throw new BusinessException(403, "账号已被封禁");
}
String role = account.isAdmin() ? "ADMIN" : "USER";
return new LoginResult(jwtUtil.generate(userId, Map.of("appKey", appKey, "role", role)), account.isAdmin());
}
public LoginResult login(String appKey, String userId) {
@ -134,6 +150,14 @@ public class ImAccountService {
return accountRepository.searchByKeyword(appKey, keyword, PageRequest.of(0, Math.max(size, 1)));
}
public org.springframework.data.domain.Page<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(
String userId,
String nickname,

查看文件

@ -27,7 +27,7 @@ public class SecurityConfig {
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/push/internal/**", "/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/push/internal/**", "/api/push/auth/**", "/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

查看文件

@ -2,6 +2,7 @@ package com.xuqm.push.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushDiagnosticsService;
import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
@ -13,9 +14,11 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/push/internal")
@ -23,13 +26,17 @@ public class InternalPushController {
private final PushDispatcher pushDispatcher;
private final PushDiagnosticsService diagnosticsService;
private final PushAccountService accountService;
@Value("${push.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) {
public InternalPushController(PushDispatcher pushDispatcher,
PushDiagnosticsService diagnosticsService,
PushAccountService accountService) {
this.pushDispatcher = pushDispatcher;
this.diagnosticsService = diagnosticsService;
this.accountService = accountService;
}
@PostMapping("/notify")
@ -88,6 +95,36 @@ public class InternalPushController {
return ResponseEntity.ok(ApiResponse.success(result));
}
@GetMapping("/accounts")
public ResponseEntity<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) {
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.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.entity.PushUserEntity;
import com.xuqm.push.service.PushAccountService;
import com.xuqm.push.service.PushDiagnosticsService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@ -21,11 +27,88 @@ import java.util.Map;
public class PushManagementController {
private final PushDiagnosticsService diagnosticsService;
private final PushAccountService accountService;
public PushManagementController(PushDiagnosticsService diagnosticsService) {
public PushManagementController(PushDiagnosticsService diagnosticsService,
PushAccountService accountService) {
this.diagnosticsService = diagnosticsService;
this.accountService = accountService;
}
// ---- user account management ----
@GetMapping("/users")
public ResponseEntity<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")
public ResponseEntity<ApiResponse<PushDiagnosticsService.PushTokenDiagnostics>> userStatus(
@RequestParam String appKey,
@ -59,11 +142,10 @@ public class PushManagementController {
return ResponseEntity.ok(ApiResponse.success(result));
}
public record TestOfflineRequest(
String appKey,
String userId,
String title,
String body,
String payload
) {}
public record UpdateUserRequest(String appKey, String nickname, String avatar, String gender) {}
public record UserStatusRequest(String appKey, String status) {}
public record ImportUserRequest(String appKey, String userId, String nickname,
String avatar, String gender, String status) {}
public record TestOfflineRequest(String appKey, String userId,
String title, String body, String payload) {}
}

查看文件

@ -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.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.TenantRepository;
import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.AppUserClient;
import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.OperationLogService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
@ -32,14 +35,20 @@ public class AppController {
private final EmailService emailService;
private final OperationLogService operationLogService;
private final TenantRepository tenantRepository;
private final FeatureServiceManager featureServiceManager;
private final AppUserClient appUserClient;
public AppController(AppService appService, EmailService emailService,
OperationLogService operationLogService,
TenantRepository tenantRepository) {
TenantRepository tenantRepository,
FeatureServiceManager featureServiceManager,
AppUserClient appUserClient) {
this.appService = appService;
this.emailService = emailService;
this.operationLogService = operationLogService;
this.tenantRepository = tenantRepository;
this.featureServiceManager = featureServiceManager;
this.appUserClient = appUserClient;
}
@GetMapping
@ -118,4 +127,31 @@ public class AppController {
String newSecret = appService.resetSecret(appKey, tenantId);
return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", newSecret)));
}
/**
* List users of an app. Queries IM accounts when IM is enabled, Push accounts otherwise.
* The ?source=IM|PUSH param overrides auto-detection.
*/
@GetMapping("/{appKey}/users")
public ResponseEntity<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,
platform,
req == null ? null : req.pushConfig());
case FILE -> featureServiceManager.buildFileConfig(appKey, platform);
};
FeatureServiceEntity saved = featureServiceManager.updateConfig(
appKey, platform, serviceType, config);

查看文件

@ -20,7 +20,7 @@ public class FeatureServiceEntity {
private static final SecureRandom RANDOM = new SecureRandom();
public enum Platform { ANDROID, IOS, HARMONY }
public enum ServiceType { IM, PUSH, UPDATE }
public enum ServiceType { IM, PUSH, UPDATE, FILE }
@Id
private String id;

查看文件

@ -22,6 +22,12 @@ public class OpsAdminEntity {
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(unique = true, length = 128)
private String email;
@Column
private LocalDateTime lastLoginAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
@ -33,4 +39,10 @@ public class OpsAdminEntity {
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
}

查看文件

@ -3,7 +3,9 @@ package com.xuqm.tenant.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.tenant.dto.CreateAppRequest;
import com.xuqm.tenant.entity.AppEntity;
import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.repository.AppRepository;
import com.xuqm.tenant.repository.FeatureServiceRepository;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
@ -19,11 +21,15 @@ public class AppService {
private final AppRepository appRepository;
private final OperationLogService operationLogService;
private final FeatureServiceRepository featureServiceRepository;
private static final SecureRandom random = new SecureRandom();
public AppService(AppRepository appRepository, OperationLogService operationLogService) {
public AppService(AppRepository appRepository,
OperationLogService operationLogService,
FeatureServiceRepository featureServiceRepository) {
this.appRepository = appRepository;
this.operationLogService = operationLogService;
this.featureServiceRepository = featureServiceRepository;
}
public List<AppEntity> listByTenant(String tenantId) {
@ -56,6 +62,7 @@ public class AppService {
app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now());
AppEntity saved = appRepository.save(app);
autoEnableFileService(saved.getAppKey());
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP", Map.of(
"name", saved.getName(),
"packageName", saved.getPackageName(),
@ -112,6 +119,19 @@ public class AppService {
return newSecret;
}
private void autoEnableFileService(String appKey) {
for (FeatureServiceEntity.Platform platform : FeatureServiceEntity.Platform.values()) {
FeatureServiceEntity entity = new FeatureServiceEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppKey(appKey);
entity.setPlatform(platform);
entity.setServiceType(FeatureServiceEntity.ServiceType.FILE);
entity.setEnabled(true);
entity.setCreatedAt(LocalDateTime.now());
featureServiceRepository.save(entity);
}
}
private String generateAppKey() {
return "ak_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
}

查看文件

@ -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(
FeatureServiceEntity.ServiceType.IM,
FeatureServiceEntity.ServiceType.PUSH,
FeatureServiceEntity.ServiceType.UPDATE)) {
FeatureServiceEntity.ServiceType.UPDATE,
FeatureServiceEntity.ServiceType.FILE)) {
services.stream()
.filter(service -> service.getServiceType() == serviceType)
.findFirst()
@ -475,6 +476,17 @@ public class FeatureServiceManager {
return node.toString();
}
public String buildFileConfig(String appKey, FeatureServiceEntity.Platform platform) {
ObjectNode node = readConfigNode(appKey, platform, FeatureServiceEntity.ServiceType.FILE).deepCopy();
if (!node.has("maxFileSizeMb")) {
node.put("maxFileSizeMb", 100);
}
if (!node.has("allowedTypes")) {
node.putArray("allowedTypes");
}
return node.toString();
}
public String buildPushConfig(String appKey,
FeatureServiceEntity.Platform platform,
JsonNode pushConfig) {
@ -539,7 +551,8 @@ public class FeatureServiceManager {
private boolean isAppWideService(FeatureServiceEntity.ServiceType serviceType) {
return serviceType == FeatureServiceEntity.ServiceType.IM
|| serviceType == FeatureServiceEntity.ServiceType.PUSH
|| serviceType == FeatureServiceEntity.ServiceType.UPDATE;
|| serviceType == FeatureServiceEntity.ServiceType.UPDATE
|| serviceType == FeatureServiceEntity.ServiceType.FILE;
}
private JsonNode readConfigNode(String appKey,

查看文件

@ -20,6 +20,7 @@ import java.util.UUID;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.AppStoreService;
import com.xuqm.update.service.ImPushUserClient;
@RestController
@RequestMapping("/api/v1/updates")
@ -33,16 +34,20 @@ public class AppVersionController {
private final AppStoreService appStoreService;
private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient;
public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
AppStoreService appStoreService,
UpdateOperationLogService operationLogService) {
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient) {
this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient;
}
@GetMapping("/app/check")
@ -72,14 +77,7 @@ public class AppVersionController {
// Gray release filtering
if (!allowAnonymousCheck && v.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false;
if ("MEMBERS".equals(v.getGrayMode()) && v.getGrayMemberIds() != null) {
inGray = v.getGrayMemberIds().contains(userId);
} else {
// PERCENT mode: deterministic hash-based sampling
int hash = Math.abs(userId.hashCode()) % 100;
inGray = hash < v.getGrayPercent();
}
boolean inGray = isInGrayRelease(v, userId);
if (!inGray) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
@ -159,7 +157,7 @@ public class AppVersionController {
entity.setCreatedAt(LocalDateTime.now());
entity.setStoreSubmitMode("MANUAL");
entity.setStoreSubmitScheduledAt(null);
entity.setGrayMode("PERCENT");
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) {
try {
@ -242,8 +240,9 @@ public class AppVersionController {
}
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
@ -294,26 +293,45 @@ public class AppVersionController {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
entity.setGrayEnabled(enabled);
if (!enabled) {
entity.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
} else if ("MEMBERS".equals(grayMode)) {
List<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);
entity.setGrayCallbackUrl(null);
} else {
entity.setGrayMode("PERCENT");
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
AppVersionEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode);
switch (grayMode) {
case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
}
case IM_PUSH_USERS -> {
entity.setGrayPercent(0);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
}
case CUSTOMER_SYNC -> {
List<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);
AppVersionEntity saved = versionRepository.save(entity);
@ -325,13 +343,47 @@ public class AppVersionController {
null,
Map.of(
"enabled", enabled,
"grayMode", saved.getGrayMode(),
"grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
private AppVersionEntity.GrayMode parseGrayMode(Object raw) {
if (raw == null) {
return AppVersionEntity.GrayMode.PERCENT;
}
try {
return AppVersionEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase());
} catch (IllegalArgumentException e) {
return AppVersionEntity.GrayMode.PERCENT;
}
}
private boolean isInGrayRelease(AppVersionEntity v, String userId) {
return switch (v.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent();
case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(v.getAppKey(), userId);
case CUSTOMER_SYNC -> v.getGrayMemberIds() != null && v.getGrayMemberIds().contains(userId);
case CUSTOMER_CALLBACK -> resolveCallbackGray(v, userId);
};
}
private boolean resolveCallbackGray(AppVersionEntity v, String userId) {
if (v.getGrayCallbackUrl() == null || v.getGrayCallbackUrl().isBlank()) {
return false;
}
try {
List<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")
public ResponseEntity<ApiResponse<List<AppVersionEntity>>> list(
@RequestParam String appKey, @RequestParam AppVersionEntity.Platform platform) {

查看文件

@ -17,15 +17,21 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.ImPushUserClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
@RequestMapping("/api/v1/rn")
public class RnBundleController {
private static final Logger log = LoggerFactory.getLogger(RnBundleController.class);
private final RnBundleRepository bundleRepository;
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final UpdateOperationLogService operationLogService;
private final ImPushUserClient imPushUserClient;
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl;
@ -33,11 +39,13 @@ public class RnBundleController {
public RnBundleController(RnBundleRepository bundleRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
UpdateOperationLogService operationLogService) {
UpdateOperationLogService operationLogService,
ImPushUserClient imPushUserClient) {
this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.operationLogService = operationLogService;
this.imPushUserClient = imPushUserClient;
}
@GetMapping("/update/check")
@ -66,14 +74,7 @@ public class RnBundleController {
RnBundleEntity b = latest.get();
boolean needsUpdate = !b.getVersion().equals(currentVersion);
if (!allowAnonymousCheck && b.isGrayEnabled() && userId != null && !userId.isBlank()) {
boolean inGray = false;
if ("MEMBERS".equals(b.getGrayMode()) && b.getGrayMemberIds() != null) {
inGray = b.getGrayMemberIds().contains(userId);
} else {
int hash = Math.abs(userId.hashCode()) % 100;
inGray = hash < b.getGrayPercent();
}
if (!inGray) {
if (!isInGrayRelease(b, userId)) {
needsUpdate = false;
}
}
@ -127,7 +128,7 @@ public class RnBundleController {
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setPublishMode("MANUAL");
entity.setScheduledPublishAt(null);
entity.setGrayMode("PERCENT");
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setCreatedAt(LocalDateTime.now());
RnBundleEntity saved = bundleRepository.save(entity);
@ -190,8 +191,9 @@ public class RnBundleController {
}
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppKey(),
@ -241,26 +243,45 @@ public class RnBundleController {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
String grayMode = body.get("grayMode") == null ? "PERCENT" : body.get("grayMode").toString().trim().toUpperCase();
entity.setGrayEnabled(enabled);
if (!enabled) {
entity.setGrayMode(RnBundleEntity.GrayMode.PERCENT);
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
} else if ("MEMBERS".equals(grayMode)) {
List<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);
entity.setGrayCallbackUrl(null);
} else {
entity.setGrayMode("PERCENT");
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
RnBundleEntity.GrayMode grayMode = parseGrayMode(body.get("grayMode"));
entity.setGrayMode(grayMode);
switch (grayMode) {
case PERCENT -> {
entity.setGrayPercent(body.get("percent") instanceof Number n ? n.intValue() : 0);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
}
case IM_PUSH_USERS -> {
entity.setGrayPercent(0);
entity.setGrayMemberIds(null);
entity.setGrayCallbackUrl(null);
}
case CUSTOMER_SYNC -> {
List<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);
RnBundleEntity saved = bundleRepository.save(entity);
@ -273,13 +294,47 @@ public class RnBundleController {
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion(),
"grayMode", saved.getGrayMode(),
"grayMode", saved.getGrayMode().name(),
"grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
private RnBundleEntity.GrayMode parseGrayMode(Object raw) {
if (raw == null) {
return RnBundleEntity.GrayMode.PERCENT;
}
try {
return RnBundleEntity.GrayMode.valueOf(raw.toString().trim().toUpperCase());
} catch (IllegalArgumentException e) {
return RnBundleEntity.GrayMode.PERCENT;
}
}
private boolean isInGrayRelease(RnBundleEntity b, String userId) {
return switch (b.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < b.getGrayPercent();
case IM_PUSH_USERS -> imPushUserClient.isImOrPushUser(b.getAppKey(), userId);
case CUSTOMER_SYNC -> b.getGrayMemberIds() != null && b.getGrayMemberIds().contains(userId);
case CUSTOMER_CALLBACK -> resolveRnCallbackGray(b, userId);
};
}
private boolean resolveRnCallbackGray(RnBundleEntity b, String userId) {
if (b.getGrayCallbackUrl() == null || b.getGrayCallbackUrl().isBlank()) {
return false;
}
try {
List<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() {
String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
String suffix = "/api/v1/updates";

查看文件

@ -16,6 +16,14 @@ public class AppVersionEntity {
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
/** Per-store review state used in storeReviewStatus JSON values. */
public enum StoreReviewState { PENDING, SUBMITTING, UNDER_REVIEW, APPROVED, REJECTED }
/**
* Gray release mode.
* PERCENT: deterministic hash-based percentage of all users.
* IM_PUSH_USERS: only users who have an IM or Push account for this appKey.
* CUSTOMER_SYNC: member list synced from the customer's user server (stored in grayMemberIds).
* CUSTOMER_CALLBACK: callback to customer URL on each update check to resolve eligible members.
*/
public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK }
@Id
private String id;
@ -89,14 +97,18 @@ public class AppVersionEntity {
@Column(length = 512)
private String webhookUrl;
/** Gray release mode: PERCENT or MEMBERS. */
@Column(length = 16)
private String grayMode = "PERCENT";
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private GrayMode grayMode = GrayMode.PERCENT;
/** JSON array of gray member userIds. */
/** JSON array of gray member userIds (used by CUSTOMER_SYNC mode). */
@Column(columnDefinition = "TEXT")
private String grayMemberIds;
/** Callback URL for CUSTOMER_CALLBACK gray mode. */
@Column(length = 512)
private String grayCallbackUrl;
/** App package name / bundle identifier, e.g. com.example.myapp */
@Column(length = 256)
private String packageName;
@ -170,9 +182,12 @@ public class AppVersionEntity {
public String getWebhookUrl() { return webhookUrl; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
public String getGrayMode() { return grayMode; }
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
public GrayMode getGrayMode() { return grayMode; }
public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; }
public String getGrayMemberIds() { return grayMemberIds; }
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
public String getGrayCallbackUrl() { return grayCallbackUrl; }
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; }
}

查看文件

@ -14,6 +14,7 @@ public class RnBundleEntity {
public enum Platform { ANDROID, IOS, HARMONY }
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
public enum GrayMode { PERCENT, IM_PUSH_USERS, CUSTOMER_SYNC, CUSTOMER_CALLBACK }
@Id
private String id;
@ -61,12 +62,16 @@ public class RnBundleEntity {
@Column(nullable = false)
private int grayPercent = 0;
@Column(length = 16)
private String grayMode = "PERCENT";
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private GrayMode grayMode = GrayMode.PERCENT;
@Column(columnDefinition = "TEXT")
private String grayMemberIds;
@Column(length = 512)
private String grayCallbackUrl;
@Column(nullable = false)
private LocalDateTime createdAt;
@ -115,12 +120,15 @@ public class RnBundleEntity {
public int getGrayPercent() { return grayPercent; }
public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; }
public String getGrayMode() { return grayMode; }
public void setGrayMode(String grayMode) { this.grayMode = grayMode; }
public GrayMode getGrayMode() { return grayMode; }
public void setGrayMode(GrayMode grayMode) { this.grayMode = grayMode; }
public String getGrayMemberIds() { return grayMemberIds; }
public void setGrayMemberIds(String grayMemberIds) { this.grayMemberIds = grayMemberIds; }
public String getGrayCallbackUrl() { return grayCallbackUrl; }
public void setGrayCallbackUrl(String grayCallbackUrl) { this.grayCallbackUrl = grayCallbackUrl; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -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();
}
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) {
JsonNode config = getConfigNode(appKey);
String url = config.path("grayDirectorySyncCallbackUrl").asText("");
@ -146,16 +154,33 @@ public class PublishConfigService {
if (url == null || url.isBlank()) {
throw new IllegalStateException("graySelectCallbackUrl is not configured");
}
String secret = config.path("graySelectCallbackSecret").asText("");
return resolveGrayMembersFromUrl(url, appKey, null, secret, requestBody);
}
public List<String> resolveGrayMembersFromUrl(String url, String appKey, String userId) {
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);
String secret = config.path("graySelectCallbackSecret").asText("");
if (!secret.isBlank()) {
ResponseEntity<String> response = restTemplate.exchange(
URI.create(url), org.springframework.http.HttpMethod.POST,
new org.springframework.http.HttpEntity<>(payload, headers), String.class);
return extractMemberIds(response.getBody());
}
private List<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);
}
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_IAM = "https://iam.developer.honor.com";
private final RestTemplate rest = new RestTemplate();
private final RestTemplate rest = buildRestTemplate();
private final AppVersionRepository versionRepo;
private final AppStoreConfigRepository configRepo;
private final AppStoreService storeService;
@ -332,6 +332,11 @@ public class StoreSubmissionService {
list = asMapList(body.get("data"));
}
if (list.isEmpty()) {
// Huawei may return the single entry directly as {key:appName, value:appId}
String directId = firstText(body, "value", "id", "appId", "app_id");
if (!directId.isBlank()) {
return directId;
}
throw new RuntimeException("Huawei: app not found for " + packageName + ", response=" + summarizeMap(body));
}
String appId = firstText(list.get(0), "id", "appId", "app_id", "value");
@ -788,7 +793,11 @@ public class StoreSubmissionService {
params.put("pkg_name", requirePackageName(v));
params.put("version_code", String.valueOf(parseVersionCode(v.getVersionCode())));
params.put("apk_url", apkUrl.toString());
params.put("update_desc", v.getChangeLog() == null ? "" : v.getChangeLog());
String oppoUpdateDesc = v.getChangeLog() == null ? "" : v.getChangeLog();
if (oppoUpdateDesc.length() < 5) {
oppoUpdateDesc = oppoUpdateDesc + " ".substring(0, 5 - oppoUpdateDesc.length());
}
params.put("update_desc", oppoUpdateDesc);
params.put("online_type", "1");
params.put("second_category_id", appInfo.path("ver_second_category_id").asText(""));
params.put("third_category_id", appInfo.path("ver_third_category_id").asText(""));
@ -914,7 +923,7 @@ public class StoreSubmissionService {
params.put("target_app_key", "developer");
String data = params.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + "=" + entry.getValue())
.map(entry -> entry.getKey() + "=" + vivoEncodeValue(entry.getValue()))
.reduce((a, b) -> a + "&" + b)
.orElse("");
String sign = hmacSha256(data, accessSecret);
@ -934,6 +943,26 @@ public class StoreSubmissionService {
}
}
private static RestTemplate buildRestTemplate() {
org.springframework.http.client.SimpleClientHttpRequestFactory factory =
new org.springframework.http.client.SimpleClientHttpRequestFactory();
factory.setConnectTimeout(30_000);
factory.setReadTimeout(300_000); // 5 minutes for large APK uploads
return new RestTemplate(factory);
}
private String vivoEncodeValue(String value) {
if (value == null) return "";
try {
return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("*", "%2A")
.replace("%7E", "~");
} catch (Exception e) {
return value;
}
}
private String hmacSha256(String data, String key) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");