feat(push): 添加多厂商推送集成支持
- 实现了华为 HMS 推送服务集成 - 实现了小米推送服务集成 - 实现了 OPPO 推送服务集成 - 实现了 vivo 推送服务集成 - 实现了荣耀推送服务集成 - 实现了 FCM 推送服务集成 - 添加了统一的厂商推送接口和检测机制 - 添加了推送配置 API 和存储管理 - 添加了推送令牌管理和设备注册功能 - 添加了模拟器环境的推送测试用例
这个提交包含在:
父节点
1baa020b74
当前提交
99d6a8d6a4
@ -39,7 +39,7 @@ public class SecurityConfig {
|
|||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
.requestMatchers("/api/im/auth/**", "/ws/**", "/actuator/**").permitAll()
|
.requestMatchers("/api/im/auth/**", "/api/im/internal/**", "/ws/**", "/actuator/**").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
|
.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);
|
return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean allowMultiDeviceLogin(String appId) {
|
||||||
|
return readConfig(appId).path("allowMultiDeviceLogin").asBoolean(true);
|
||||||
|
}
|
||||||
|
|
||||||
private JsonNode readConfig(String appId) {
|
private JsonNode readConfig(String appId) {
|
||||||
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
|
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
|
||||||
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
package com.xuqm.push.controller;
|
package com.xuqm.push.controller;
|
||||||
|
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.push.entity.DeviceLoginLogEntity;
|
||||||
|
import com.xuqm.push.service.PushDiagnosticsService;
|
||||||
import com.xuqm.push.service.PushDispatcher;
|
import com.xuqm.push.service.PushDispatcher;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
@ -18,12 +22,14 @@ import java.util.List;
|
|||||||
public class InternalPushController {
|
public class InternalPushController {
|
||||||
|
|
||||||
private final PushDispatcher pushDispatcher;
|
private final PushDispatcher pushDispatcher;
|
||||||
|
private final PushDiagnosticsService diagnosticsService;
|
||||||
|
|
||||||
@Value("${push.internal-token:xuqm-internal-token}")
|
@Value("${push.internal-token:xuqm-internal-token}")
|
||||||
private String internalToken;
|
private String internalToken;
|
||||||
|
|
||||||
public InternalPushController(PushDispatcher pushDispatcher) {
|
public InternalPushController(PushDispatcher pushDispatcher, PushDiagnosticsService diagnosticsService) {
|
||||||
this.pushDispatcher = pushDispatcher;
|
this.pushDispatcher = pushDispatcher;
|
||||||
|
this.diagnosticsService = diagnosticsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/notify")
|
@PostMapping("/notify")
|
||||||
@ -37,6 +43,55 @@ public class InternalPushController {
|
|||||||
return ResponseEntity.ok(ApiResponse.ok());
|
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(
|
public record NotifyRequest(
|
||||||
@NotBlank String appId,
|
@NotBlank String appId,
|
||||||
List<@NotBlank String> userIds,
|
List<@NotBlank String> userIds,
|
||||||
@ -44,4 +99,12 @@ public class InternalPushController {
|
|||||||
@NotBlank String body,
|
@NotBlank String body,
|
||||||
String payload
|
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 appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
|
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
|
||||||
@RequestParam @NotBlank String token) {
|
@RequestParam @NotBlank String token,
|
||||||
pushDispatcher.registerToken(appId, userId, vendor, 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());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,8 +42,9 @@ public class PushController {
|
|||||||
public ResponseEntity<ApiResponse<Void>> receivePush(
|
public ResponseEntity<ApiResponse<Void>> receivePush(
|
||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
|
@RequestParam(required = false) String deviceId,
|
||||||
@RequestParam boolean enabled) {
|
@RequestParam boolean enabled) {
|
||||||
pushDispatcher.setReceivePush(appId, userId, enabled);
|
pushDispatcher.setReceivePush(appId, userId, deviceId, enabled);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
return ResponseEntity.ok(ApiResponse.ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,8 +63,9 @@ public class PushController {
|
|||||||
public ResponseEntity<ApiResponse<Void>> unregister(
|
public ResponseEntity<ApiResponse<Void>> unregister(
|
||||||
@RequestParam @NotBlank String appId,
|
@RequestParam @NotBlank String appId,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor) {
|
@RequestParam @NotNull DeviceTokenEntity.Vendor vendor,
|
||||||
pushDispatcher.unregisterToken(appId, userId, vendor);
|
@RequestParam(required = false) String deviceId) {
|
||||||
|
pushDispatcher.unregisterToken(appId, userId, vendor, deviceId);
|
||||||
return ResponseEntity.ok(ApiResponse.ok());
|
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
|
@Entity
|
||||||
@Table(name = "push_device_token",
|
@Table(name = "push_device_token",
|
||||||
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "vendor"}))
|
uniqueConstraints = @UniqueConstraint(columnNames = {"appId", "userId", "deviceId"}))
|
||||||
public class DeviceTokenEntity {
|
public class DeviceTokenEntity {
|
||||||
|
|
||||||
public enum Vendor {
|
public enum Vendor {
|
||||||
@ -34,9 +34,30 @@ public class DeviceTokenEntity {
|
|||||||
@Column(nullable = false, length = 512)
|
@Column(nullable = false, length = 512)
|
||||||
private String token;
|
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)
|
@Column(nullable = false)
|
||||||
private boolean receivePush = true;
|
private boolean receivePush = true;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime lastLoginAt;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@ -58,9 +79,30 @@ public class DeviceTokenEntity {
|
|||||||
public String getToken() { return token; }
|
public String getToken() { return token; }
|
||||||
public void setToken(String token) { this.token = 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 boolean isReceivePush() { return receivePush; }
|
||||||
public void setReceivePush(boolean receivePush) { this.receivePush = 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 LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = 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> {
|
public interface DeviceTokenRepository extends JpaRepository<DeviceTokenEntity, String> {
|
||||||
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
|
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId);
|
||||||
|
Optional<DeviceTokenEntity> findFirstByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(
|
||||||
|
String appId, String userId);
|
||||||
|
List<DeviceTokenEntity> findByAppIdAndUserIdAndReceivePushTrueOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId);
|
||||||
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
Optional<DeviceTokenEntity> findByAppIdAndUserIdAndVendor(
|
||||||
String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
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> findByAppIdAndUserId(String appId, String userId);
|
||||||
|
List<DeviceTokenEntity> findByAppIdAndUserIdOrderByLastLoginAtDescUpdatedAtDesc(String appId, String userId);
|
||||||
void deleteByAppIdAndUserIdAndVendor(String appId, String userId, DeviceTokenEntity.Vendor vendor);
|
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;
|
package com.xuqm.push.service;
|
||||||
|
|
||||||
import com.xuqm.push.entity.DeviceTokenEntity;
|
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.repository.DeviceTokenRepository;
|
||||||
import com.xuqm.push.service.provider.PushProvider;
|
import com.xuqm.push.service.provider.PushProvider;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -8,6 +10,9 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -20,25 +25,40 @@ public class PushDispatcher {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class);
|
private static final Logger log = LoggerFactory.getLogger(PushDispatcher.class);
|
||||||
|
|
||||||
private final DeviceTokenRepository tokenRepository;
|
private final DeviceTokenRepository tokenRepository;
|
||||||
|
private final DeviceLoginLogRepository logRepository;
|
||||||
|
private final TenantImConfigClient imConfigClient;
|
||||||
private final Map<String, PushProvider> providers;
|
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.tokenRepository = tokenRepository;
|
||||||
|
this.logRepository = logRepository;
|
||||||
|
this.imConfigClient = imConfigClient;
|
||||||
this.providers = providerList.stream()
|
this.providers = providerList.stream()
|
||||||
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
.collect(Collectors.toMap(PushProvider::vendorName, p -> p));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
public void pushToUser(String appId, String userId, String title, String body, String payload) {
|
||||||
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId);
|
List<DeviceTokenEntity> targets = selectTargets(appId, userId);
|
||||||
for (DeviceTokenEntity t : tokens) {
|
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());
|
PushProvider provider = providers.get(t.getVendor().name());
|
||||||
if (provider != null) {
|
if (provider != null) {
|
||||||
boolean ok = provider.send(appId, t.getToken(), title, body, payload);
|
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) {
|
public void pushToUsers(String appId, List<String> userIds, String title, String body, String payload) {
|
||||||
if (userIds == null || userIds.isEmpty()) {
|
if (userIds == null || userIds.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -48,33 +68,142 @@ public class PushDispatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) {
|
private List<DeviceTokenEntity> selectTargets(String appId, String userId) {
|
||||||
Optional<DeviceTokenEntity> existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor);
|
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 entity = existing.orElseGet(() -> {
|
||||||
DeviceTokenEntity e = new DeviceTokenEntity();
|
DeviceTokenEntity e = new DeviceTokenEntity();
|
||||||
e.setId(UUID.randomUUID().toString());
|
e.setId(UUID.randomUUID().toString());
|
||||||
e.setAppId(appId);
|
e.setAppId(appId);
|
||||||
e.setUserId(userId);
|
e.setUserId(userId);
|
||||||
e.setVendor(vendor);
|
e.setVendor(vendor);
|
||||||
e.setCreatedAt(LocalDateTime.now());
|
e.setCreatedAt(now);
|
||||||
return e;
|
return e;
|
||||||
});
|
});
|
||||||
|
entity.setVendor(vendor);
|
||||||
entity.setToken(token);
|
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.setReceivePush(true);
|
||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setLastLoginAt(now);
|
||||||
tokenRepository.save(entity);
|
entity.setUpdatedAt(now);
|
||||||
|
DeviceTokenEntity saved = tokenRepository.save(entity);
|
||||||
|
recordLog(saved, DeviceLoginLogEntity.EventType.REGISTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setReceivePush(String appId, String userId, boolean enabled) {
|
public void setReceivePush(String appId, String userId, String deviceId, boolean enabled) {
|
||||||
List<DeviceTokenEntity> tokens = tokenRepository.findByAppIdAndUserId(appId, userId);
|
List<DeviceTokenEntity> tokens = deviceId == null || deviceId.isBlank()
|
||||||
|
? tokenRepository.findByAppIdAndUserId(appId, userId)
|
||||||
|
: tokenRepository.findByAppIdAndUserIdAndDeviceId(appId, userId, deviceId).stream().toList();
|
||||||
for (DeviceTokenEntity token : tokens) {
|
for (DeviceTokenEntity token : tokens) {
|
||||||
token.setReceivePush(enabled);
|
token.setReceivePush(enabled);
|
||||||
token.setUpdatedAt(LocalDateTime.now());
|
token.setUpdatedAt(LocalDateTime.now());
|
||||||
|
recordLog(token, DeviceLoginLogEntity.EventType.RECEIVE_PUSH_UPDATE);
|
||||||
}
|
}
|
||||||
tokenRepository.saveAll(tokens);
|
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);
|
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.messageRecallMinutes(),
|
||||||
req == null ? null : req.historyRetentionDays(),
|
req == null ? null : req.historyRetentionDays(),
|
||||||
req == null ? null : req.conversationPullLimit(),
|
req == null ? null : req.conversationPullLimit(),
|
||||||
|
req == null ? null : req.allowMultiDeviceLogin(),
|
||||||
req == null ? null : req.multiClientConversationDeleteSync());
|
req == null ? null : req.multiClientConversationDeleteSync());
|
||||||
case UPDATE -> featureServiceManager.buildUpdateConfig(
|
case UPDATE -> featureServiceManager.buildUpdateConfig(
|
||||||
appId,
|
appId,
|
||||||
@ -110,26 +111,26 @@ public class FeatureServiceController {
|
|||||||
case PUSH -> featureServiceManager.buildPushConfig(
|
case PUSH -> featureServiceManager.buildPushConfig(
|
||||||
appId,
|
appId,
|
||||||
platform,
|
platform,
|
||||||
req == null ? null : req.huaweiAppId(),
|
req == null ? null : req.huaweiAppIdValue(),
|
||||||
req == null ? null : req.huaweiAppSecret(),
|
req == null ? null : req.huaweiAppSecretValue(),
|
||||||
req == null ? null : req.xiaomiAppId(),
|
req == null ? null : req.xiaomiAppIdValue(),
|
||||||
req == null ? null : req.xiaomiAppKey(),
|
req == null ? null : req.xiaomiAppKeyValue(),
|
||||||
req == null ? null : req.xiaomiAppSecret(),
|
req == null ? null : req.xiaomiAppSecretValue(),
|
||||||
req == null ? null : req.oppoAppId(),
|
req == null ? null : req.oppoAppIdValue(),
|
||||||
req == null ? null : req.oppoAppKey(),
|
req == null ? null : req.oppoAppKeyValue(),
|
||||||
req == null ? null : req.oppoMasterSecret(),
|
req == null ? null : req.oppoMasterSecretValue(),
|
||||||
req == null ? null : req.vivoAppId(),
|
req == null ? null : req.vivoAppIdValue(),
|
||||||
req == null ? null : req.vivoAppKey(),
|
req == null ? null : req.vivoAppKeyValue(),
|
||||||
req == null ? null : req.vivoAppSecret(),
|
req == null ? null : req.vivoAppSecretValue(),
|
||||||
req == null ? null : req.honorAppId(),
|
req == null ? null : req.honorAppIdValue(),
|
||||||
req == null ? null : req.honorClientId(),
|
req == null ? null : req.honorClientIdValue(),
|
||||||
req == null ? null : req.honorClientSecret(),
|
req == null ? null : req.honorClientSecretValue(),
|
||||||
req == null ? null : req.apnsTeamId(),
|
req == null ? null : req.apnsTeamIdValue(),
|
||||||
req == null ? null : req.apnsKeyId(),
|
req == null ? null : req.apnsKeyIdValue(),
|
||||||
req == null ? null : req.apnsBundleId(),
|
req == null ? null : req.apnsBundleIdValue(),
|
||||||
req == null ? null : req.apnsKeyPath(),
|
req == null ? null : req.apnsKeyPathValue(),
|
||||||
req != null && Boolean.TRUE.equals(req.apnsSandbox()),
|
req != null && req.apnsSandboxValue(),
|
||||||
req == null ? null : req.fcmServiceAccountJson());
|
req == null ? null : req.fcmServiceAccountJsonValue());
|
||||||
};
|
};
|
||||||
FeatureServiceEntity saved = featureServiceManager.updateConfig(
|
FeatureServiceEntity saved = featureServiceManager.updateConfig(
|
||||||
appId, platform, serviceType, config);
|
appId, platform, serviceType, config);
|
||||||
@ -190,6 +191,7 @@ public class FeatureServiceController {
|
|||||||
Integer messageRecallMinutes,
|
Integer messageRecallMinutes,
|
||||||
Integer historyRetentionDays,
|
Integer historyRetentionDays,
|
||||||
Integer conversationPullLimit,
|
Integer conversationPullLimit,
|
||||||
|
Boolean allowMultiDeviceLogin,
|
||||||
Boolean multiClientConversationDeleteSync,
|
Boolean multiClientConversationDeleteSync,
|
||||||
List<String> defaultStoreTargets,
|
List<String> defaultStoreTargets,
|
||||||
String defaultPublishMode,
|
String defaultPublishMode,
|
||||||
@ -222,6 +224,63 @@ public class FeatureServiceController {
|
|||||||
String apnsBundleId,
|
String apnsBundleId,
|
||||||
String apnsKeyPath,
|
String apnsKeyPath,
|
||||||
Boolean apnsSandbox,
|
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.RiskConfigEntity;
|
||||||
import com.xuqm.tenant.entity.SensitiveWordEntity;
|
import com.xuqm.tenant.entity.SensitiveWordEntity;
|
||||||
import com.xuqm.tenant.service.OpsService;
|
import com.xuqm.tenant.service.OpsService;
|
||||||
|
import com.xuqm.tenant.service.OpsPushDiagnosticsClient;
|
||||||
import com.xuqm.tenant.service.RiskControlService;
|
import com.xuqm.tenant.service.RiskControlService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@ -34,12 +35,15 @@ public class OpsController {
|
|||||||
private final OpsService opsService;
|
private final OpsService opsService;
|
||||||
private final FeatureServiceManager featureServiceManager;
|
private final FeatureServiceManager featureServiceManager;
|
||||||
private final RiskControlService riskControlService;
|
private final RiskControlService riskControlService;
|
||||||
|
private final OpsPushDiagnosticsClient pushDiagnosticsClient;
|
||||||
|
|
||||||
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
|
public OpsController(OpsService opsService, FeatureServiceManager featureServiceManager,
|
||||||
RiskControlService riskControlService) {
|
RiskControlService riskControlService,
|
||||||
|
OpsPushDiagnosticsClient pushDiagnosticsClient) {
|
||||||
this.opsService = opsService;
|
this.opsService = opsService;
|
||||||
this.featureServiceManager = featureServiceManager;
|
this.featureServiceManager = featureServiceManager;
|
||||||
this.riskControlService = riskControlService;
|
this.riskControlService = riskControlService;
|
||||||
|
this.pushDiagnosticsClient = pushDiagnosticsClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/auth/ops/login")
|
@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")
|
@GetMapping("/api/ops/risk/rules")
|
||||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||||
|
|||||||
@ -66,6 +66,10 @@ public class SdkConfigController {
|
|||||||
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
|
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
|
||||||
.map(feature -> parseConfig(feature.getConfig()))
|
.map(feature -> parseConfig(feature.getConfig()))
|
||||||
.orElseGet(objectMapper::createObjectNode);
|
.orElseGet(objectMapper::createObjectNode);
|
||||||
|
JsonNode pushConfig = featureServiceRepository
|
||||||
|
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.PUSH)
|
||||||
|
.map(feature -> parseConfig(feature.getConfig()))
|
||||||
|
.orElseGet(objectMapper::createObjectNode);
|
||||||
boolean updateEnabled = featureServiceRepository
|
boolean updateEnabled = featureServiceRepository
|
||||||
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
|
.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, FeatureServiceEntity.ServiceType.UPDATE)
|
||||||
.map(FeatureServiceEntity::isEnabled)
|
.map(FeatureServiceEntity::isEnabled)
|
||||||
@ -92,7 +96,8 @@ public class SdkConfigController {
|
|||||||
updateConfig.path("defaultGrayPercent").asInt(0),
|
updateConfig.path("defaultGrayPercent").asInt(0),
|
||||||
updateConfig.path("defaultPackageName").asText(""),
|
updateConfig.path("defaultPackageName").asText(""),
|
||||||
updateConfig.path("defaultAppStoreUrl").asText(""),
|
updateConfig.path("defaultAppStoreUrl").asText(""),
|
||||||
updateConfig.path("defaultMarketUrl").asText("")
|
updateConfig.path("defaultMarketUrl").asText(""),
|
||||||
|
pushConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
@ -115,7 +120,8 @@ public class SdkConfigController {
|
|||||||
int updateDefaultGrayPercent,
|
int updateDefaultGrayPercent,
|
||||||
String updateDefaultPackageName,
|
String updateDefaultPackageName,
|
||||||
String updateDefaultAppStoreUrl,
|
String updateDefaultAppStoreUrl,
|
||||||
String updateDefaultMarketUrl
|
String updateDefaultMarketUrl,
|
||||||
|
JsonNode pushConfig
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private JsonNode parseConfig(String config) {
|
private JsonNode parseConfig(String config) {
|
||||||
|
|||||||
@ -280,6 +280,7 @@ public class FeatureServiceManager {
|
|||||||
Integer messageRecallMinutes,
|
Integer messageRecallMinutes,
|
||||||
Integer historyRetentionDays,
|
Integer historyRetentionDays,
|
||||||
Integer conversationPullLimit,
|
Integer conversationPullLimit,
|
||||||
|
Boolean allowMultiDeviceLogin,
|
||||||
Boolean multiClientConversationDeleteSync) {
|
Boolean multiClientConversationDeleteSync) {
|
||||||
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy();
|
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy();
|
||||||
if (!node.has("allowStrangerMessage")) {
|
if (!node.has("allowStrangerMessage")) {
|
||||||
@ -309,6 +310,9 @@ public class FeatureServiceManager {
|
|||||||
if (!node.has("multiClientConversationDeleteSync")) {
|
if (!node.has("multiClientConversationDeleteSync")) {
|
||||||
node.put("multiClientConversationDeleteSync", false);
|
node.put("multiClientConversationDeleteSync", false);
|
||||||
}
|
}
|
||||||
|
if (!node.has("allowMultiDeviceLogin")) {
|
||||||
|
node.put("allowMultiDeviceLogin", true);
|
||||||
|
}
|
||||||
if (allowStrangerMessage != null) {
|
if (allowStrangerMessage != null) {
|
||||||
node.put("allowStrangerMessage", allowStrangerMessage);
|
node.put("allowStrangerMessage", allowStrangerMessage);
|
||||||
}
|
}
|
||||||
@ -342,6 +346,9 @@ public class FeatureServiceManager {
|
|||||||
if (conversationPullLimit != null) {
|
if (conversationPullLimit != null) {
|
||||||
node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500));
|
node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500));
|
||||||
}
|
}
|
||||||
|
if (allowMultiDeviceLogin != null) {
|
||||||
|
node.put("allowMultiDeviceLogin", allowMultiDeviceLogin);
|
||||||
|
}
|
||||||
if (multiClientConversationDeleteSync != null) {
|
if (multiClientConversationDeleteSync != null) {
|
||||||
node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync);
|
node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync);
|
||||||
}
|
}
|
||||||
@ -358,6 +365,7 @@ public class FeatureServiceManager {
|
|||||||
node.put("messageRecallMinutes", 2);
|
node.put("messageRecallMinutes", 2);
|
||||||
node.put("historyRetentionDays", 7);
|
node.put("historyRetentionDays", 7);
|
||||||
node.put("conversationPullLimit", 100);
|
node.put("conversationPullLimit", 100);
|
||||||
|
node.put("allowMultiDeviceLogin", true);
|
||||||
node.put("multiClientConversationDeleteSync", false);
|
node.put("multiClientConversationDeleteSync", false);
|
||||||
return node.toString();
|
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.setServiceType(FeatureServiceEntity.ServiceType.IM);
|
||||||
feature.setEnabled(true);
|
feature.setEnabled(true);
|
||||||
feature.setConfig("""
|
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());
|
""".trim());
|
||||||
feature.setCreatedAt(LocalDateTime.now());
|
feature.setCreatedAt(LocalDateTime.now());
|
||||||
return featureServiceRepository.save(feature);
|
return featureServiceRepository.save(feature);
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户