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>
这个提交包含在:
XuqmGroup 2026-05-06 07:16:39 +08:00
父节点 7dc00f18bf
当前提交 538022b5f0
共有 5 个文件被更改,包括 117 次插入15 次删除

查看文件

@ -63,9 +63,15 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
token = token.substring(7); token = token.substring(7);
if (jwtUtil.isValid(token)) { if (jwtUtil.isValid(token)) {
String userId = jwtUtil.getSubject(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 = UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userId, null, new UsernamePasswordAuthenticationToken(userId, null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))); List.of(new SimpleGrantedAuthority("ROLE_USER")));
auth.setDetails(java.util.Map.of("appId", appId));
accessor.setUser(auth); accessor.setUser(auth);
userPresenceService.markOnline(userId); userPresenceService.markOnline(userId);
} }

查看文件

@ -77,6 +77,14 @@ public class ImFeatureConfigClient {
return readConfig(appId).path("allowMultiDeviceLogin").asBoolean(true); 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) { 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}")

查看文件

@ -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()) { if (devices.isEmpty()) {
return List.of(); return List.of();
} }
if (!imConfigClient.allowMultiDeviceLogin(appId)) { String mode = imConfigClient.multiDeviceLoginMode(appId);
return List.of(devices.get(0)); return switch (mode) {
} case "SINGLE_DEVICE" -> List.of(devices.get(0));
return devices.stream() case "SAME_PLATFORM_ONE" -> devices.stream()
.collect(Collectors.toMap( .collect(Collectors.toMap(
DeviceTokenEntity::getVendor, d -> d.getPlatform() != null ? d.getPlatform() : d.getVendor().name(),
device -> device, d -> d,
(first, ignored) -> first, (first, ignored) -> first,
java.util.LinkedHashMap::new)) java.util.LinkedHashMap::new))
.values() .values().stream().toList();
.stream() default -> devices.stream()
.toList(); .collect(Collectors.toMap(
DeviceTokenEntity::getVendor,
d -> d,
(first, ignored) -> first,
java.util.LinkedHashMap::new))
.values().stream().toList();
};
} }
public void registerToken(String appId, public void registerToken(String appId,

查看文件

@ -30,7 +30,7 @@ public class TenantImConfigClient {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
public boolean allowMultiDeviceLogin(String appId) { public String multiDeviceLoginMode(String appId) {
try { try {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.set("X-Internal-Token", internalToken); headers.set("X-Internal-Token", internalToken);
@ -42,11 +42,25 @@ public class TenantImConfigClient {
JsonNode body = response.getBody(); JsonNode body = response.getBody();
String config = body == null ? "" : body.path("data").path("config").asText(""); String config = body == null ? "" : body.path("data").path("config").asText("");
if (!config.isBlank()) { 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) { } catch (Exception e) {
log.warn("load tenant im config failed appId={} reason={}", appId, e.getMessage()); 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));
} }
} }