feat(push): 添加多厂商推送集成支持

- 实现了华为 HMS 推送服务集成
- 实现了小米推送服务集成
- 实现了 OPPO 推送服务集成
- 实现了 vivo 推送服务集成
- 实现了荣耀推送服务集成
- 实现了 FCM 推送服务集成
- 添加了统一的厂商推送接口和检测机制
- 添加了推送配置 API 和存储管理
- 添加了推送令牌管理和设备注册功能
- 添加了模拟器环境的推送测试用例
这个提交包含在:
XuqmGroup 2026-05-05 17:54:59 +08:00
父节点 1baa020b74
当前提交 99d6a8d6a4
共有 19 个文件被更改,包括 985 次插入45 次删除

查看文件

@ -39,7 +39,7 @@ public class SecurityConfig {
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll()
.requestMatchers("/api/im/auth/**", "/api/im/internal/**", "/ws/**", "/actuator/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

查看文件

@ -0,0 +1,73 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.im.service.UserPresenceService;
import io.jsonwebtoken.Claims;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
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.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;
@RestController
@RequestMapping("/api/im/internal/presence")
public class InternalPresenceController {
private final JwtUtil jwtUtil;
private final UserPresenceService presenceService;
@Value("${im.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalPresenceController(JwtUtil jwtUtil, UserPresenceService presenceService) {
this.jwtUtil = jwtUtil;
this.presenceService = presenceService;
}
@PostMapping("/resolve-token")
public ResponseEntity<ApiResponse<PresenceStatus>> resolveToken(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@RequestBody TokenRequest request) {
if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
try {
Claims claims = jwtUtil.parse(request.token());
String userId = claims.getSubject();
String appId = claims.get("appId", String.class);
return ResponseEntity.ok(ApiResponse.success(status(appId, userId)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(ApiResponse.error(400, "Invalid IM token"));
}
}
@GetMapping("/users/{userId}")
public ResponseEntity<ApiResponse<PresenceStatus>> userStatus(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@RequestParam String appId,
@PathVariable String userId) {
if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
return ResponseEntity.ok(ApiResponse.success(status(appId, userId)));
}
private PresenceStatus status(String appId, String userId) {
boolean online = presenceService.isOnline(userId);
return new PresenceStatus(appId, userId, online, presenceService.lastSeenAt(userId));
}
private boolean isAllowed(String token) {
return token != null && internalToken.equals(token);
}
public record TokenRequest(String token) {}
public record PresenceStatus(String appId, String userId, boolean online, long lastSeenAt) {}
}

查看文件

@ -73,6 +73,10 @@ public class ImFeatureConfigClient {
return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false);
}
public boolean allowMultiDeviceLogin(String appId) {
return readConfig(appId).path("allowMultiDeviceLogin").asBoolean(true);
}
private JsonNode readConfig(String appId) {
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")

查看文件

@ -1,10 +1,14 @@
package com.xuqm.push.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.service.PushDiagnosticsService;
import com.xuqm.push.service.PushDispatcher;
import jakarta.validation.constraints.NotBlank;
import org.springframework.data.domain.Page;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@ -18,12 +22,14 @@ import java.util.List;
public class InternalPushController {
private final PushDispatcher pushDispatcher;
private final PushDiagnosticsService diagnosticsService;
@Value("${push.internal-token:xuqm-internal-token}")
private String internalToken;
public InternalPushController(PushDispatcher pushDispatcher) {
public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) {
this.pushDispatcher = pushDispatcher;
this.diagnosticsService = diagnosticsService;
}
@PostMapping("/notify")
@ -37,6 +43,55 @@ public class InternalPushController {
return ResponseEntity.ok(ApiResponse.ok());
}
@GetMapping("/diagnostics/search")
public ResponseEntity<ApiResponse<PushDiagnosticsService.PushTokenDiagnostics>> searchByToken(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@org.springframework.web.bind.annotation.RequestParam String queryToken,
@org.springframework.web.bind.annotation.RequestParam(required = false) String appId) {
if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
return ResponseEntity.ok(ApiResponse.success(diagnosticsService.searchByToken(queryToken, appId)));
}
@GetMapping("/device-logs")
public ResponseEntity<ApiResponse<java.util.Map<String, Object>>> deviceLogs(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@org.springframework.web.bind.annotation.RequestParam String appId,
@org.springframework.web.bind.annotation.RequestParam String userId,
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "20") int size) {
if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
Page<DeviceLoginLogEntity> result = diagnosticsService.deviceLogs(appId, userId, page, size);
return ResponseEntity.ok(ApiResponse.success(java.util.Map.of(
"content", result.getContent(),
"total", result.getTotalElements(),
"totalPages", result.getTotalPages()
)));
}
@PostMapping("/test-offline")
public ResponseEntity<ApiResponse<PushDiagnosticsService.TestPushResult>> testOffline(
@RequestHeader(value = "X-Internal-Token", required = false) String token,
@RequestBody TestOfflineRequest request) {
if (!isAllowed(token)) {
return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden"));
}
PushDiagnosticsService.TestPushResult result = diagnosticsService.sendTestOfflineMessage(
request.appId(),
request.userId(),
request.title(),
request.body(),
request.payload());
return ResponseEntity.ok(ApiResponse.success(result));
}
private boolean isAllowed(String token) {
return token != null && internalToken.equals(token);
}
public record NotifyRequest(
@NotBlank String appId,
List<@NotBlank String> userIds,
@ -44,4 +99,12 @@ public class InternalPushController {
@NotBlank String body,
String payload
) {}
public record TestOfflineRequest(
@NotBlank String appId,
@NotBlank String userId,
@NotBlank String title,
@NotBlank String body,
String payload
) {}
}

查看文件

@ -27,8 +27,14 @@ public class PushController {
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
@RequestParam @NotBlank String token) {
pushDispatcher.registerToken(appId, userId, vendor, token);
@RequestParam @NotBlank 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) {
pushDispatcher.registerToken(appId, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -36,8 +42,9 @@ public class PushController {
public ResponseEntity<ApiResponse<Void>> receivePush(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam(required = false) String deviceId,
@RequestParam boolean enabled) {
pushDispatcher.setReceivePush(appId, userId, enabled);
pushDispatcher.setReceivePush(appId, userId, deviceId, enabled);
return ResponseEntity.ok(ApiResponse.ok());
}
@ -56,8 +63,9 @@ public class PushController {
public ResponseEntity<ApiResponse<Void>> unregister(
@RequestParam @NotBlank String appId,
@RequestParam @NotBlank String userId,
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor) {
pushDispatcher.unregisterToken(appId, userId, vendor);
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
@RequestParam(required = false) String deviceId) {
pushDispatcher.unregisterToken(appId, userId, vendor, deviceId);
return ResponseEntity.ok(ApiResponse.ok());
}
}

查看文件

@ -0,0 +1,114 @@
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.Index;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "push_device_login_log", indexes = {
@Index(name = "idx_push_device_log_user_time", columnList = "appId,userId,createdAt"),
@Index(name = "idx_push_device_log_token", columnList = "tokenHash")
})
public class DeviceLoginLogEntity {
public enum EventType {
REGISTER, UNREGISTER, RECEIVE_PUSH_UPDATE
}
@Id
private String id;
@Column(nullable = false, length = 64)
private String appId;
@Column(nullable = false, length = 128)
private String userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private DeviceTokenEntity.Vendor vendor;
@Column(nullable = false, length = 128)
private String tokenHash;
@Column(nullable = false, length = 128)
private String tokenPreview;
@Column(length = 32)
private String platform;
@Column(nullable = false, length = 128)
private String deviceId;
@Column(length = 64)
private String brand;
@Column(length = 128)
private String model;
@Column(length = 64)
private String osVersion;
@Column(length = 64)
private String appVersion;
@Column(nullable = false)
private boolean receivePush;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private EventType eventType;
@Column(nullable = false)
private LocalDateTime createdAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppId() { return appId; }
public void setAppId(String appId) { this.appId = appId; }
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public DeviceTokenEntity.Vendor getVendor() { return vendor; }
public void setVendor(DeviceTokenEntity.Vendor vendor) { this.vendor = vendor; }
public String getTokenHash() { return tokenHash; }
public void setTokenHash(String tokenHash) { this.tokenHash = tokenHash; }
public String getTokenPreview() { return tokenPreview; }
public void setTokenPreview(String tokenPreview) { this.tokenPreview = tokenPreview; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getOsVersion() { return osVersion; }
public void setOsVersion(String osVersion) { this.osVersion = osVersion; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public boolean isReceivePush() { return receivePush; }
public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; }
public EventType getEventType() { return eventType; }
public void setEventType(EventType eventType) { this.eventType = eventType; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

查看文件

@ -11,7 +11,7 @@ import java.time.LocalDateTime;
@Entity
@Table(name = "push_device_token",
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"}))
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "deviceId"}))
public class DeviceTokenEntity {
public enum Vendor {
@ -34,9 +34,30 @@ public class DeviceTokenEntity {
@Column(nullable = false, length = 512)
private String token;
@Column(length = 32)
private String platform;
@Column(length = 128)
private String deviceId;
@Column(length = 64)
private String brand;
@Column(length = 128)
private String model;
@Column(length = 64)
private String osVersion;
@Column(length = 64)
private String appVersion;
@Column(nullable = false)
private boolean receivePush = true;
@Column
private LocalDateTime lastLoginAt;
@Column(nullable = false)
private LocalDateTime createdAt;
@ -58,9 +79,30 @@ public class DeviceTokenEntity {
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getDeviceId() { return deviceId; }
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getOsVersion() { return osVersion; }
public void setOsVersion(String osVersion) { this.osVersion = osVersion; }
public String getAppVersion() { return appVersion; }
public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
public boolean isReceivePush() { return receivePush; }
public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; }
public LocalDateTime getLastLoginAt() { return lastLoginAt; }
public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }

查看文件

@ -0,0 +1,10 @@
package com.xuqm.push.repository;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DeviceLoginLogRepository extends JpaRepository<DeviceLoginLogEntity, String> {
Page<DeviceLoginLogEntity> findByAppIdAndUserIdOrderByCreatedAtDesc(String appId, String userId, Pageable pageable);
}

查看文件

@ -8,8 +8,15 @@ import java.util.Optional;
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
Optional<DeviceTokenEntity> findFirstByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(
String appId, String userId);
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId);
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
String appId, String userId, DeviceTokenEntity.Vendor vendor);
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndDeviceId(String appId, String userId, String deviceId);
Optional<DeviceTokenEntity> findFirstByToken(String token);
List<DeviceTokenEntity> findByAppIdAndUserId(String appId, String userId);
List<DeviceTokenEntity> findByAppIdAndUserIdOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId);
void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor);
void deleteByAppIdAndUserIdAndDeviceId(String appId, String userId, String deviceId);
}

查看文件

@ -0,0 +1,90 @@
package com.xuqm.push.service;
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.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Map;
import java.util.Optional;
@Component
public class ImPresenceClient {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper;
@Value("${push.im-service-base-url:http://im-service:8082}")
private String imServiceBaseUrl;
@Value("${push.internal-token:xuqm-internal-token}")
private String internalToken;
public ImPresenceClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Optional<PresenceStatus> resolveToken(String token) {
try {
HttpHeaders headers = internalHeaders();
HttpEntity<String> request = new HttpEntity<>(
objectMapper.writeValueAsString(Map.of("token", token)),
headers);
String body = restTemplate.postForObject(
imServiceBaseUrl + "/api/im/internal/presence/resolve-token",
request,
String.class);
return parse(body);
} catch (Exception ignored) {
return Optional.empty();
}
}
public Optional<PresenceStatus> userStatus(String appId, String userId) {
try {
HttpHeaders headers = internalHeaders();
HttpEntity<Void> request = new HttpEntity<>(headers);
String uri = UriComponentsBuilder.fromHttpUrl(imServiceBaseUrl + "/api/im/internal/presence/users/{userId}")
.queryParam("appId", appId)
.build(userId)
.toString();
String body = restTemplate.exchange(
uri,
org.springframework.http.HttpMethod.GET,
request,
String.class).getBody();
return parse(body);
} catch (Exception ignored) {
return Optional.empty();
}
}
private HttpHeaders internalHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Internal-Token", internalToken);
return headers;
}
private Optional<PresenceStatus> parse(String body) throws Exception {
if (body == null || body.isBlank()) {
return Optional.empty();
}
JsonNode data = objectMapper.readTree(body).path("data");
if (data.isMissingNode() || data.isNull()) {
return Optional.empty();
}
return Optional.of(new PresenceStatus(
data.path("appId").asText(null),
data.path("userId").asText(null),
data.path("online").asBoolean(false),
data.path("lastSeenAt").asLong(0L)));
}
public record PresenceStatus(String appId, String userId, boolean online, long lastSeenAt) {}
}

查看文件

@ -0,0 +1,157 @@
package com.xuqm.push.service;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.repository.DeviceLoginLogRepository;
import com.xuqm.push.repository.DeviceTokenRepository;
import io.jsonwebtoken.Claims;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
public class PushDiagnosticsService {
private final DeviceTokenRepository tokenRepository;
private final DeviceLoginLogRepository logRepository;
private final ImPresenceClient presenceClient;
private final PushDispatcher pushDispatcher;
private final JwtUtil jwtUtil;
public PushDiagnosticsService(DeviceTokenRepository tokenRepository,
DeviceLoginLogRepository logRepository,
ImPresenceClient presenceClient,
PushDispatcher pushDispatcher,
JwtUtil jwtUtil) {
this.tokenRepository = tokenRepository;
this.logRepository = logRepository;
this.presenceClient = presenceClient;
this.pushDispatcher = pushDispatcher;
this.jwtUtil = jwtUtil;
}
public PushTokenDiagnostics searchByToken(String token, String appIdHint) {
Optional<DeviceTokenEntity> tokenMatch = tokenRepository.findFirstByToken(token);
String appId = appIdHint;
String userId = null;
String tokenType = "UNKNOWN";
if (tokenMatch.isPresent()) {
DeviceTokenEntity device = tokenMatch.get();
appId = device.getAppId();
userId = device.getUserId();
tokenType = "PUSH";
} else {
Optional<ImPresenceClient.PresenceStatus> resolved = presenceClient.resolveToken(token);
if (resolved.isPresent()) {
appId = resolved.get().appId();
userId = resolved.get().userId();
tokenType = "IM";
} else {
try {
Claims claims = jwtUtil.parse(token);
userId = claims.getSubject();
String claimAppId = claims.get("appId", String.class);
appId = claimAppId == null || claimAppId.isBlank() ? appIdHint : claimAppId;
tokenType = "IM";
} catch (Exception ignored) {
// Keep UNKNOWN and return an empty diagnostic.
}
}
}
if (appId == null || appId.isBlank() || userId == null || userId.isBlank()) {
return new PushTokenDiagnostics(tokenType, appId, userId, false, 0L, false, null, List.of(), List.of());
}
ImPresenceClient.PresenceStatus presence = presenceClient.userStatus(appId, userId)
.orElse(new ImPresenceClient.PresenceStatus(appId, userId, false, 0L));
List<DeviceTokenEntity> devices = tokenRepository.findByAppIdAndUserIdOrderByLastLoginAtDescUpdatedAtDesc(appId, userId);
List<DeviceInfo> deliverableDevices = pushDispatcher.selectedPushTargets(appId, userId)
.stream()
.map(DeviceInfo::from)
.toList();
DeviceInfo deliverable = deliverableDevices.isEmpty() ? null : deliverableDevices.get(0);
boolean canSendOffline = !presence.online() && !deliverableDevices.isEmpty();
return new PushTokenDiagnostics(
tokenType,
appId,
userId,
presence.online(),
presence.lastSeenAt(),
canSendOffline,
deliverable,
deliverableDevices,
devices.stream().map(DeviceInfo::from).toList());
}
public TestPushResult sendTestOfflineMessage(String appId, String userId, String title, String body, String payload) {
List<DeviceInfo> targets = pushDispatcher.selectedPushTargets(appId, userId)
.stream()
.map(DeviceInfo::from)
.toList();
if (!targets.isEmpty()) {
pushDispatcher.pushToUser(appId, userId, title, body, payload);
}
return new TestPushResult(appId, userId, !targets.isEmpty(), targets.size(), targets);
}
public Page<DeviceLoginLogEntity> deviceLogs(String appId, String userId, int page, int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 200);
return logRepository.findByAppIdAndUserIdOrderByCreatedAtDesc(appId, userId, PageRequest.of(safePage, safeSize));
}
public record PushTokenDiagnostics(
String tokenType,
String appId,
String userId,
boolean online,
long lastSeenAt,
boolean canSendOfflineMessage,
DeviceInfo deliverableDevice,
List<DeviceInfo> deliverableDevices,
List<DeviceInfo> devices) {}
public record TestPushResult(
String appId,
String userId,
boolean sent,
int targetCount,
List<DeviceInfo> targets) {}
public record DeviceInfo(
String id,
String vendor,
String tokenPreview,
String platform,
String deviceId,
String brand,
String model,
String osVersion,
String appVersion,
boolean receivePush,
LocalDateTime lastLoginAt,
LocalDateTime updatedAt) {
static DeviceInfo from(DeviceTokenEntity entity) {
return new DeviceInfo(
entity.getId(),
entity.getVendor().name(),
PushDispatcher.previewToken(entity.getToken()),
entity.getPlatform(),
entity.getDeviceId(),
entity.getBrand(),
entity.getModel(),
entity.getOsVersion(),
entity.getAppVersion(),
entity.isReceivePush(),
entity.getLastLoginAt(),
entity.getUpdatedAt());
}
}
}

查看文件

@ -1,6 +1,8 @@
package com.xuqm.push.service;
import com.xuqm.push.entity.DeviceTokenEntity;
import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.repository.DeviceLoginLogRepository;
import com.xuqm.push.repository.DeviceTokenRepository;
import com.xuqm.push.service.provider.PushProvider;
import org.slf4j.Logger;
@ -8,6 +10,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@ -20,25 +25,40 @@ public class PushDispatcher {
private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class);
private final DeviceTokenRepository tokenRepository;
private final DeviceLoginLogRepository logRepository;
private final TenantImConfigClient imConfigClient;
private final Map<String, PushProvider> providers;
public PushDispatcher(DeviceTokenRepository tokenRepository, List<PushProvider> providerList) {
public PushDispatcher(DeviceTokenRepository tokenRepository,
DeviceLoginLogRepository logRepository,
TenantImConfigClient imConfigClient,
List<PushProvider> providerList) {
this.tokenRepository = tokenRepository;
this.logRepository = logRepository;
this.imConfigClient = imConfigClient;
this.providers = providerList.stream()
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
}
public void pushToUser(String appId, String userId, String title, String body, String payload) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
for (DeviceTokenEntity t : tokens) {
List<DeviceTokenEntity> targets = selectTargets(appId, userId);
if (targets.isEmpty()) {
log.info("Skip push to {}@{}: no receive-enabled device", userId, appId);
return;
}
for (DeviceTokenEntity t : targets) {
PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) {
boolean ok = provider.send(appId, t.getToken(), title, body, payload);
log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL");
log.info("Push to {}@{} via {} deviceId={}: {}", userId, appId, t.getVendor(), t.getDeviceId(), ok ? "OK" : "FAIL");
}
}
}
public List<DeviceTokenEntity> selectedPushTargets(String appId, String userId) {
return selectTargets(appId, userId);
}
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
if (userIds == null || userIds.isEmpty()) {
return;
@ -48,33 +68,142 @@ public class PushDispatcher {
}
}
public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) {
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
private List<DeviceTokenEntity> selectTargets(String appId, String userId) {
List<DeviceTokenEntity> devices = tokenRepository
.findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(appId, userId);
if (devices.isEmpty()) {
return List.of();
}
if (!imConfigClient.allowMultiDeviceLogin(appId)) {
return List.of(devices.get(0));
}
return devices.stream()
.collect(Collectors.toMap(
DeviceTokenEntity::getVendor,
device -> device,
(first, ignored) -> first,
java.util.LinkedHashMap::new))
.values()
.stream()
.toList();
}
public void registerToken(String appId,
String userId,
DeviceTokenEntity.Vendor vendor,
String token,
String platform,
String deviceId,
String brand,
String model,
String osVersion,
String appVersion) {
String resolvedDeviceId = normalizeDeviceId(deviceId, vendor, token);
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, resolvedDeviceId);
if (existing.isEmpty() && (deviceId == null || deviceId.isBlank())) {
existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
}
LocalDateTime now = LocalDateTime.now();
DeviceTokenEntity entity = existing.orElseGet(() -> {
DeviceTokenEntity e = new DeviceTokenEntity();
e.setId(UUID.randomUUID().toString());
e.setAppId(appId);
e.setUserId(userId);
e.setVendor(vendor);
e.setCreatedAt(LocalDateTime.now());
e.setCreatedAt(now);
return e;
});
entity.setVendor(vendor);
entity.setToken(token);
entity.setPlatform(blankToNull(platform));
entity.setDeviceId(resolvedDeviceId);
entity.setBrand(blankToNull(brand));
entity.setModel(blankToNull(model));
entity.setOsVersion(blankToNull(osVersion));
entity.setAppVersion(blankToNull(appVersion));
entity.setReceivePush(true);
entity.setUpdatedAt(LocalDateTime.now());
tokenRepository.save(entity);
entity.setLastLoginAt(now);
entity.setUpdatedAt(now);
DeviceTokenEntity saved = tokenRepository.save(entity);
recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER);
}
public void setReceivePush(String appId, String userId, boolean enabled) {
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
public void setReceivePush(String appId, String userId, String deviceId, boolean enabled) {
List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank()
? tokenRepository.findByAppIdAndUserId(appId, userId)
: tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId).stream().toList();
for (DeviceTokenEntity token : tokens) {
token.setReceivePush(enabled);
token.setUpdatedAt(LocalDateTime.now());
recordLog(token, DeviceLoginLogEntity.EventType.RECEIVE_PUSH_UPDATE);
}
tokenRepository.saveAll(tokens);
}
public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor) {
public void unregisterToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String deviceId) {
if (deviceId != null && !deviceId.isBlank()) {
tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId)
.ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER));
tokenRepository.deleteByAppIdAndUserIdAndDeviceId(appId, userId, deviceId);
return;
}
tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor)
.ifPresent(entity -> recordLog(entity, DeviceLoginLogEntity.EventType.UNREGISTER));
tokenRepository.deleteByAppIdAndUserIdAndVendor(appId, userId, vendor);
}
private void recordLog(DeviceTokenEntity token, DeviceLoginLogEntity.EventType eventType) {
DeviceLoginLogEntity entity = new DeviceLoginLogEntity();
entity.setId(UUID.randomUUID().toString());
entity.setAppId(token.getAppId());
entity.setUserId(token.getUserId());
entity.setVendor(token.getVendor());
entity.setTokenHash(hashToken(token.getToken()));
entity.setTokenPreview(previewToken(token.getToken()));
entity.setPlatform(token.getPlatform());
entity.setDeviceId(token.getDeviceId());
entity.setBrand(token.getBrand());
entity.setModel(token.getModel());
entity.setOsVersion(token.getOsVersion());
entity.setAppVersion(token.getAppVersion());
entity.setReceivePush(token.isReceivePush());
entity.setEventType(eventType);
entity.setCreatedAt(LocalDateTime.now());
logRepository.save(entity);
}
private String normalizeDeviceId(String deviceId, DeviceTokenEntity.Vendor vendor, String token) {
if (deviceId != null && !deviceId.isBlank()) {
return deviceId.trim();
}
return vendor.name() + ":" + hashToken(token).substring(0, 24);
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String hashToken(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(token.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
public static String previewToken(String token) {
if (token == null || token.isBlank()) {
return "";
}
if (token.length() <= 16) {
return token;
}
return token.substring(0, 8) + "..." + token.substring(token.length() - 8);
}
}

查看文件

@ -0,0 +1,52 @@
package com.xuqm.push.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.RestTemplate;
@Component
public class TenantImConfigClient {
private static final Logger log = LoggerFactory.getLogger(TenantImConfigClient.class);
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper;
@Value("${push.tenant-service-base-url:http://tenant-service:8081}")
private String tenantServiceBaseUrl;
@Value("${push.internal-token:xuqm-internal-token}")
private String internalToken;
public TenantImConfigClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public boolean allowMultiDeviceLogin(String appId) {
try {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
ResponseEntity<JsonNode> response = restTemplate.exchange(
tenantServiceBaseUrl + "/api/internal/sdk/apps/" + appId + "/services/ANDROID/IM",
HttpMethod.GET,
new HttpEntity<>(headers),
JsonNode.class);
JsonNode body = response.getBody();
String config = body == null ? "" : body.path("data").path("config").asText("");
if (!config.isBlank()) {
return objectMapper.readTree(config).path("allowMultiDeviceLogin").asBoolean(true);
}
} catch (Exception e) {
log.warn("load tenant im config failed appId={} reason={}", appId, e.getMessage());
}
return true;
}
}

查看文件

@ -91,6 +91,7 @@ public class FeatureServiceController {
req == null ? null : req.messageRecallMinutes(),
req == null ? null : req.historyRetentionDays(),
req == null ? null : req.conversationPullLimit(),
req == null ? null : req.allowMultiDeviceLogin(),
req == null ? null : req.multiClientConversationDeleteSync());
case UPDATE -> featureServiceManager.buildUpdateConfig(
appId,
@ -110,26 +111,26 @@ public class FeatureServiceController {
case PUSH -> featureServiceManager.buildPushConfig(
appId,
platform,
req == null ? null : req.huaweiAppId(),
req == null ? null : req.huaweiAppSecret(),
req == null ? null : req.xiaomiAppId(),
req == null ? null : req.xiaomiAppKey(),
req == null ? null : req.xiaomiAppSecret(),
req == null ? null : req.oppoAppId(),
req == null ? null : req.oppoAppKey(),
req == null ? null : req.oppoMasterSecret(),
req == null ? null : req.vivoAppId(),
req == null ? null : req.vivoAppKey(),
req == null ? null : req.vivoAppSecret(),
req == null ? null : req.honorAppId(),
req == null ? null : req.honorClientId(),
req == null ? null : req.honorClientSecret(),
req == null ? null : req.apnsTeamId(),
req == null ? null : req.apnsKeyId(),
req == null ? null : req.apnsBundleId(),
req == null ? null : req.apnsKeyPath(),
req != null && Boolean.TRUE.equals(req.apnsSandbox()),
req == null ? null : req.fcmServiceAccountJson());
req == null ? null : req.huaweiAppIdValue(),
req == null ? null : req.huaweiAppSecretValue(),
req == null ? null : req.xiaomiAppIdValue(),
req == null ? null : req.xiaomiAppKeyValue(),
req == null ? null : req.xiaomiAppSecretValue(),
req == null ? null : req.oppoAppIdValue(),
req == null ? null : req.oppoAppKeyValue(),
req == null ? null : req.oppoMasterSecretValue(),
req == null ? null : req.vivoAppIdValue(),
req == null ? null : req.vivoAppKeyValue(),
req == null ? null : req.vivoAppSecretValue(),
req == null ? null : req.honorAppIdValue(),
req == null ? null : req.honorClientIdValue(),
req == null ? null : req.honorClientSecretValue(),
req == null ? null : req.apnsTeamIdValue(),
req == null ? null : req.apnsKeyIdValue(),
req == null ? null : req.apnsBundleIdValue(),
req == null ? null : req.apnsKeyPathValue(),
req != null && req.apnsSandboxValue(),
req == null ? null : req.fcmServiceAccountJsonValue());
};
FeatureServiceEntity saved = featureServiceManager.updateConfig(
appId, platform, serviceType, config);
@ -190,6 +191,7 @@ public class FeatureServiceController {
Integer messageRecallMinutes,
Integer historyRetentionDays,
Integer conversationPullLimit,
Boolean allowMultiDeviceLogin,
Boolean multiClientConversationDeleteSync,
List<String> defaultStoreTargets,
String defaultPublishMode,
@ -222,6 +224,63 @@ public class FeatureServiceController {
String apnsBundleId,
String apnsKeyPath,
Boolean apnsSandbox,
String fcmServiceAccountJson
String fcmServiceAccountJson,
PushVendorConfig huawei,
PushVendorConfig xiaomi,
PushVendorConfig oppo,
PushVendorConfig vivo,
PushVendorConfig honor,
PushVendorConfig apns,
PushVendorConfig fcm
) {
public String huaweiAppIdValue() { return firstText(huaweiAppId, huawei == null ? null : huawei.appId()); }
public String huaweiAppSecretValue() { return firstText(huaweiAppSecret, huawei == null ? null : huawei.appSecret()); }
public String xiaomiAppIdValue() { return firstText(xiaomiAppId, xiaomi == null ? null : xiaomi.appId()); }
public String xiaomiAppKeyValue() { return firstText(xiaomiAppKey, xiaomi == null ? null : xiaomi.appKey()); }
public String xiaomiAppSecretValue() { return firstText(xiaomiAppSecret, xiaomi == null ? null : xiaomi.appSecret()); }
public String oppoAppIdValue() { return firstText(oppoAppId, oppo == null ? null : oppo.appId()); }
public String oppoAppKeyValue() { return firstText(oppoAppKey, oppo == null ? null : oppo.appKey()); }
public String oppoMasterSecretValue() { return firstText(oppoMasterSecret, oppo == null ? null : oppo.masterSecret()); }
public String vivoAppIdValue() { return firstText(vivoAppId, vivo == null ? null : vivo.appId()); }
public String vivoAppKeyValue() { return firstText(vivoAppKey, vivo == null ? null : vivo.appKey()); }
public String vivoAppSecretValue() { return firstText(vivoAppSecret, vivo == null ? null : vivo.appSecret()); }
public String honorAppIdValue() { return firstText(honorAppId, honor == null ? null : honor.appId()); }
public String honorClientIdValue() { return firstText(honorClientId, honor == null ? null : honor.clientId()); }
public String honorClientSecretValue() { return firstText(honorClientSecret, honor == null ? null : honor.clientSecret()); }
public String apnsTeamIdValue() { return firstText(apnsTeamId, apns == null ? null : apns.teamId()); }
public String apnsKeyIdValue() { return firstText(apnsKeyId, apns == null ? null : apns.keyId()); }
public String apnsBundleIdValue() { return firstText(apnsBundleId, apns == null ? null : apns.bundleId()); }
public String apnsKeyPathValue() { return firstText(apnsKeyPath, apns == null ? null : apns.keyPath()); }
public boolean apnsSandboxValue() {
if (apnsSandbox != null) {
return apnsSandbox;
}
return apns != null && Boolean.TRUE.equals(apns.sandbox());
}
public String fcmServiceAccountJsonValue() {
return firstText(fcmServiceAccountJson, fcm == null ? null : fcm.serviceAccountJson());
}
private static String firstText(String first, String second) {
if (first != null) {
return first;
}
return second;
}
}
public record PushVendorConfig(
String appId,
String appKey,
String appSecret,
String masterSecret,
String clientId,
String clientSecret,
String teamId,
String keyId,
String bundleId,
String keyPath,
Boolean sandbox,
String serviceAccountJson
) {}
}

查看文件

@ -10,6 +10,7 @@ import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.entity.RiskConfigEntity;
import com.xuqm.tenant.entity.SensitiveWordEntity;
import com.xuqm.tenant.service.OpsService;
import com.xuqm.tenant.service.OpsPushDiagnosticsClient;
import com.xuqm.tenant.service.RiskControlService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@ -34,12 +35,15 @@ public class OpsController {
private final OpsService opsService;
private final FeatureServiceManager featureServiceManager;
private final RiskControlService riskControlService;
private final OpsPushDiagnosticsClient pushDiagnosticsClient;
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
RiskControlService riskControlService) {
RiskControlService riskControlService,
OpsPushDiagnosticsClient pushDiagnosticsClient) {
this.opsService = opsService;
this.featureServiceManager = featureServiceManager;
this.riskControlService = riskControlService;
this.pushDiagnosticsClient = pushDiagnosticsClient;
}
@PostMapping("/api/auth/ops/login")
@ -160,6 +164,35 @@ public class OpsController {
)));
}
@GetMapping("/api/ops/push/search")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> searchPushByToken(
@RequestParam String token,
@RequestParam(required = false) String appId) {
return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.searchByToken(token, appId)));
}
@GetMapping("/api/ops/push/device-logs")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> pushDeviceLogs(
@RequestParam String appId,
@RequestParam String userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.deviceLogs(appId, userId, page, size)));
}
@PostMapping("/api/ops/push/test-offline")
@PreAuthorize("hasAuthority('ROLE_OPS')")
public ResponseEntity<ApiResponse<Map<String, Object>>> sendPushTestOffline(@RequestBody Map<String, String> body) {
return ResponseEntity.ok(ApiResponse.success(pushDiagnosticsClient.sendTestOffline(
body.get("appId"),
body.get("userId"),
body.getOrDefault("title", "XuqmGroup Push 测试"),
body.getOrDefault("body", "这是一条离线推送测试消息"),
body.getOrDefault("payload", "{}"))));
}
/* ---------- 风控配置 ---------- */
@GetMapping("/api/ops/risk/rules")
@PreAuthorize("hasAuthority('ROLE_OPS')")

查看文件

@ -66,6 +66,10 @@ public class SdkConfigController {
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
.map(feature -> parseConfig(feature.getConfig()))
.orElseGet(objectMapper::createObjectNode);
JsonNode pushConfig = featureServiceRepository
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH)
.map(feature -> parseConfig(feature.getConfig()))
.orElseGet(objectMapper::createObjectNode);
boolean updateEnabled = featureServiceRepository
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
.map(FeatureServiceEntity::isEnabled)
@ -92,7 +96,8 @@ public class SdkConfigController {
updateConfig.path("defaultGrayPercent").asInt(0),
updateConfig.path("defaultPackageName").asText(""),
updateConfig.path("defaultAppStoreUrl").asText(""),
updateConfig.path("defaultMarketUrl").asText("")
updateConfig.path("defaultMarketUrl").asText(""),
pushConfig
);
return ResponseEntity.ok(ApiResponse.success(response));
@ -115,7 +120,8 @@ public class SdkConfigController {
int updateDefaultGrayPercent,
String updateDefaultPackageName,
String updateDefaultAppStoreUrl,
String updateDefaultMarketUrl
String updateDefaultMarketUrl,
JsonNode pushConfig
) {}
private JsonNode parseConfig(String config) {

查看文件

@ -280,6 +280,7 @@ public class FeatureServiceManager {
Integer messageRecallMinutes,
Integer historyRetentionDays,
Integer conversationPullLimit,
Boolean allowMultiDeviceLogin,
Boolean multiClientConversationDeleteSync) {
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy();
if (!node.has("allowStrangerMessage")) {
@ -309,6 +310,9 @@ public class FeatureServiceManager {
if (!node.has("multiClientConversationDeleteSync")) {
node.put("multiClientConversationDeleteSync", false);
}
if (!node.has("allowMultiDeviceLogin")) {
node.put("allowMultiDeviceLogin", true);
}
if (allowStrangerMessage != null) {
node.put("allowStrangerMessage", allowStrangerMessage);
}
@ -342,6 +346,9 @@ public class FeatureServiceManager {
if (conversationPullLimit != null) {
node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500));
}
if (allowMultiDeviceLogin != null) {
node.put("allowMultiDeviceLogin", allowMultiDeviceLogin);
}
if (multiClientConversationDeleteSync != null) {
node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync);
}
@ -358,6 +365,7 @@ public class FeatureServiceManager {
node.put("messageRecallMinutes", 2);
node.put("historyRetentionDays", 7);
node.put("conversationPullLimit", 100);
node.put("allowMultiDeviceLogin", true);
node.put("multiClientConversationDeleteSync", false);
return node.toString();
}

查看文件

@ -0,0 +1,85 @@
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.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import java.util.Map;
@Service
public class OpsPushDiagnosticsClient {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper;
@Value("${ops.push-service-base-url:http://push-service:8083}")
private String pushServiceBaseUrl;
@Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken;
public OpsPushDiagnosticsClient(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public Map<String, Object> searchByToken(String token, String appId) {
String uri = UriComponentsBuilder.fromHttpUrl(pushServiceBaseUrl + "/api/push/internal/diagnostics/search")
.queryParam("queryToken", token)
.queryParamIfPresent("appId", appId == null || appId.isBlank() ? java.util.Optional.empty() : java.util.Optional.of(appId))
.build()
.toUriString();
return dataMap(exchange(uri));
}
public Map<String, Object> deviceLogs(String appId, String userId, int page, int size) {
String uri = UriComponentsBuilder.fromHttpUrl(pushServiceBaseUrl + "/api/push/internal/device-logs")
.queryParam("appId", appId)
.queryParam("userId", userId)
.queryParam("page", page)
.queryParam("size", size)
.build()
.toUriString();
return dataMap(exchange(uri));
}
public Map<String, Object> sendTestOffline(String appId, String userId, String title, String body, String payload) {
String uri = pushServiceBaseUrl + "/api/push/internal/test-offline";
HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken);
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> request = new java.util.LinkedHashMap<>();
request.put("appId", appId);
request.put("userId", userId);
request.put("title", title);
request.put("body", body);
request.put("payload", payload);
String response = restTemplate.postForObject(uri, new HttpEntity<>(request, headers), String.class);
return dataMap(response);
}
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();
}
}
}

查看文件

@ -128,7 +128,7 @@ public class SdkAppProvisioningService {
feature.setServiceType(FeatureServiceEntity.ServiceType.IM);
feature.setEnabled(true);
feature.setConfig("""
{"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"multiClientConversationDeleteSync":false}
{"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"allowMultiDeviceLogin":true,"multiClientConversationDeleteSync":false}
""".trim());
feature.setCreatedAt(LocalDateTime.now());
return featureServiceRepository.save(feature);