feat(im/push): 互踢配置与推送可达设备精选
新增三种多端登录模式(MULTI_DEVICE_FREE / SAME_PLATFORM_ONE / SINGLE_DEVICE),在 WebSocketConfig CONNECT 时提取 appId 并存入 auth details;ImSessionKickListener 监听 SessionConnectedEvent, 向已有会话发送 KICKED 系统消息实现服务端告知踢线。 PushDispatcher.selectTargets 按登录模式精选推送设备:自由模式取 每厂商最新设备,相同平台踢旧模式取每平台最新设备,单设备模式只 取全局最新设备。TenantImConfigClient / ImFeatureConfigClient 同步 支持新配置字段,并向后兼容旧 allowMultiDeviceLogin boolean。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
7dc00f18bf
当前提交
538022b5f0
@ -63,9 +63,15 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
token = token.substring(7);
|
||||
if (jwtUtil.isValid(token)) {
|
||||
String userId = jwtUtil.getSubject(token);
|
||||
String appId = "";
|
||||
try {
|
||||
Object claim = jwtUtil.parse(token).get("appId");
|
||||
if (claim != null) appId = claim.toString();
|
||||
} catch (Exception ignored) {}
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(userId, null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
auth.setDetails(java.util.Map.of("appId", appId));
|
||||
accessor.setUser(auth);
|
||||
userPresenceService.markOnline(userId);
|
||||
}
|
||||
|
||||
@ -77,6 +77,14 @@ public class ImFeatureConfigClient {
|
||||
return readConfig(appId).path("allowMultiDeviceLogin").asBoolean(true);
|
||||
}
|
||||
|
||||
public String multiDeviceLoginMode(String appId) {
|
||||
String mode = readConfig(appId).path("multiDeviceLoginMode").asText("MULTI_DEVICE_FREE");
|
||||
return switch (mode.toUpperCase()) {
|
||||
case "SAME_PLATFORM_ONE", "SINGLE_DEVICE" -> mode.toUpperCase();
|
||||
default -> "MULTI_DEVICE_FREE";
|
||||
};
|
||||
}
|
||||
|
||||
private JsonNode readConfig(String appId) {
|
||||
String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl)
|
||||
.path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}")
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package com.xuqm.im.ws;
|
||||
|
||||
import com.xuqm.im.service.ImFeatureConfigClient;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.messaging.SessionConnectedEvent;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ImSessionKickListener {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ImSessionKickListener.class);
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final SimpUserRegistry userRegistry;
|
||||
private final ImFeatureConfigClient featureConfigClient;
|
||||
|
||||
public ImSessionKickListener(SimpMessagingTemplate messagingTemplate,
|
||||
SimpUserRegistry userRegistry,
|
||||
ImFeatureConfigClient featureConfigClient) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.userRegistry = userRegistry;
|
||||
this.featureConfigClient = featureConfigClient;
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onSessionConnected(SessionConnectedEvent event) {
|
||||
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
|
||||
Principal principal = accessor.getUser();
|
||||
if (!(principal instanceof UsernamePasswordAuthenticationToken auth)) {
|
||||
return;
|
||||
}
|
||||
String userId = auth.getName();
|
||||
String sessionId = accessor.getSessionId();
|
||||
|
||||
String appId = null;
|
||||
Object details = auth.getDetails();
|
||||
if (details instanceof Map<?, ?> detailsMap) {
|
||||
Object v = detailsMap.get("appId");
|
||||
if (v != null) appId = v.toString();
|
||||
}
|
||||
if (appId == null || appId.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String mode = featureConfigClient.multiDeviceLoginMode(appId);
|
||||
if ("MULTI_DEVICE_FREE".equals(mode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var user = userRegistry.getUser(userId);
|
||||
if (user == null || user.getSessions().size() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Sending kick to userId={} appId={} mode={} newSessionId={}", userId, appId, mode, sessionId);
|
||||
Map<String, String> payload = Map.of("type", "KICKED", "sessionId", sessionId, "reason", mode.toLowerCase());
|
||||
messagingTemplate.convertAndSendToUser(userId, "/queue/system", payload);
|
||||
}
|
||||
}
|
||||
@ -154,18 +154,24 @@ public class PushDispatcher {
|
||||
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();
|
||||
String mode = imConfigClient.multiDeviceLoginMode(appId);
|
||||
return switch (mode) {
|
||||
case "SINGLE_DEVICE" -> List.of(devices.get(0));
|
||||
case "SAME_PLATFORM_ONE" -> devices.stream()
|
||||
.collect(Collectors.toMap(
|
||||
d -> d.getPlatform() != null ? d.getPlatform() : d.getVendor().name(),
|
||||
d -> d,
|
||||
(first, ignored) -> first,
|
||||
java.util.LinkedHashMap::new))
|
||||
.values().stream().toList();
|
||||
default -> devices.stream()
|
||||
.collect(Collectors.toMap(
|
||||
DeviceTokenEntity::getVendor,
|
||||
d -> d,
|
||||
(first, ignored) -> first,
|
||||
java.util.LinkedHashMap::new))
|
||||
.values().stream().toList();
|
||||
};
|
||||
}
|
||||
|
||||
public void registerToken(String appId,
|
||||
|
||||
@ -30,7 +30,7 @@ public class TenantImConfigClient {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public boolean allowMultiDeviceLogin(String appId) {
|
||||
public String multiDeviceLoginMode(String appId) {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-Internal-Token", internalToken);
|
||||
@ -42,11 +42,25 @@ public class TenantImConfigClient {
|
||||
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);
|
||||
JsonNode tree = objectMapper.readTree(config);
|
||||
String mode = tree.path("multiDeviceLoginMode").asText("");
|
||||
if (!mode.isBlank()) {
|
||||
return switch (mode.toUpperCase()) {
|
||||
case "SAME_PLATFORM_ONE", "SINGLE_DEVICE" -> mode.toUpperCase();
|
||||
default -> "MULTI_DEVICE_FREE";
|
||||
};
|
||||
}
|
||||
// backward compat: allowMultiDeviceLogin=false → SINGLE_DEVICE
|
||||
boolean multi = tree.path("allowMultiDeviceLogin").asBoolean(true);
|
||||
return multi ? "MULTI_DEVICE_FREE" : "SINGLE_DEVICE";
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("load tenant im config failed appId={} reason={}", appId, e.getMessage());
|
||||
}
|
||||
return true;
|
||||
return "MULTI_DEVICE_FREE";
|
||||
}
|
||||
|
||||
public boolean allowMultiDeviceLogin(String appId) {
|
||||
return !"SINGLE_DEVICE".equals(multiDeviceLoginMode(appId));
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户