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,
|
||||
@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");
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户