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