JAVA:WebSocket 「在线状态 + 强制挤下线通知」

  1. 前端生成浏览器唯一标识 clientUniqueId(localStorage 永久缓存)
  2. 网页端单设备登录,换浏览器 / 电脑直接挤下线
  3. WebSocket 实时推送踢下线,不用等下次接口请求
  4. 网页 + App 隔离互不冲突
  5. SpringBoot 完整版、可直接复制上线

一、第一步:用户表新增字段

sql

复制代码
-- 网页端
web_client_id    varchar(128)  comment '前端浏览器唯一ID',
web_token        varchar(128)  comment '网页登录token',
-- 手机端
app_client_id    varchar(128),
app_token        varchar(128)

二、前端全套代码(Vue / 原生 JS 通用)

1. 生成浏览器唯一 ID(全局初始化)

js

复制代码
// 浏览器唯一指纹,本机永久不变
function getClientUniqueId() {
  let clientId = localStorage.getItem("clientUniqueId");
  if (!clientId) {
    // 生成uuid
    clientId = uuid();
    localStorage.setItem("clientUniqueId", clientId);
  }
  return clientId;
}

// uuid工具
function uuid() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  })
}

// 全局唯一ID
const clientUniqueId = getClientUniqueId();

2. 请求拦截器,每次接口自动携带请求头

js

复制代码
// axios 举例
axios.interceptors.request.use(config => {
  config.headers["client-unique-id"] = clientUniqueId;
  return config;
})

3. 登录成功后,连接 WebSocket

js

复制代码
let socket = null;

// 登录成功后调用
function connectWebSocket(userId) {
  // 后端ws地址
  const wsUrl = `ws://localhost:8080/ws/kick?userId=${userId}`;
  socket = new WebSocket(wsUrl);

  // 监听后端推送消息
  socket.onmessage = (res) => {
    if (res.data === "KICK_OUT") {
      // 被挤下线
      alert("账号已在其他设备登录,您已被迫下线!");
      // 清空本地缓存
      localStorage.removeItem("token");
      // 跳登录页
      location.href = "/login";
      // 关闭连接
      socket.close();
    }
  }

  // 连接关闭重连(可选)
  socket.onclose = () => {
    setTimeout(() => connectWebSocket(userId), 5000);
  }
}

4. 登录接口携带参数

js

复制代码
axios.post("/login",{
  username: "xxx",
  password: "xxx",
  clientUniqueId: clientUniqueId
}).then(res=>{
  if(res.code === 200){
    // 保存token
    localStorage.setItem("token",res.data.token);
    // 建立ws连接
    connectWebSocket(res.data.userId);
  }
})

三、后端 WebSocket 全套配置(SpringBoot)

1. 引入依赖

xml

复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2. WebSocket 配置类

java

运行

复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

3. WebSocket 服务端(核心踢人推送)

java

运行

复制代码
import org.springframework.stereotype.Component;
import javax.websocket.OnClose;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
@ServerEndpoint("/ws/kick")
public class KickWebSocketServer {

    // 存放在线用户:userId -> 会话
    private static final Map<Long, Session> USER_SESSION_MAP = new ConcurrentHashMap<>();

    private Session session;
    private Long userId;

    @OnOpen
    public void onOpen(Session session, @PathParam("userId") Long userId) {
        this.session = session;
        this.userId = userId;
        // 覆盖旧连接
        USER_SESSION_MAP.put(userId, session);
    }

    @OnClose
    public void onClose() {
        USER_SESSION_MAP.remove(userId);
    }

    // 给指定用户发送踢下线消息
    public static void sendKickMsg(Long userId) {
        Session session = USER_SESSION_MAP.get(userId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText("KICK_OUT");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

四、后端登录业务核心代码(挤下线逻辑)

1. 登录接口伪代码

java

运行

复制代码
@Autowired
private UserMapper userMapper;

@PostMapping("/login")
public Result login(@RequestBody UserLoginDTO dto){
    // 1. 校验账号密码
    User user = userMapper.selectByUserName(dto.getUsername());
    if(user == null || !user.getPassword().equals(dto.getPassword())){
        return Result.fail("账号密码错误");
    }

    // 2. 前端传的当前浏览器唯一ID
    String currentClientId = dto.getClientUniqueId();

    // 3. 判断:已有网页登录 且 不是当前设备
    if (org.springframework.util.StringUtils.hasText(user.getWebClientId())
            && !user.getWebClientId().equals(currentClientId)) {
        // ✅ 关键:给旧设备实时推送踢下线
        KickWebSocketServer.sendKickMsg(user.getId());
    }

    // 4. 生成新token,覆盖设备ID
    String newWebToken = UUID.randomUUID().toString().replace("-","");
    user.setWebToken(newWebToken);
    user.setWebClientId(currentClientId);
    userMapper.updateById(user);

    // 5. 返回数据给前端
    Map<String,Object> resMap = new HashMap<>();
    resMap.put("token",newWebToken);
    resMap.put("userId",user.getId());
    return Result.success(resMap);
}

五、你之前的拦截器 最终完整安全版

java

运行

复制代码
@Override
public boolean preHandle(HttpServletRequest request,
                         HttpServletResponse response,
                         Object handler) {
    // 1. 获取请求头
    String token = request.getHeader("auth");
    String clientUniqueId = request.getHeader("client-unique-id");

    // 2. 基础非空校验
    if (TextUtil.isEmpty(token)) {
        throw new BizException("登录凭证不能为空");
    }
    if (TextUtil.isEmpty(clientUniqueId)) {
        throw new BizException("设备标识不能为空");
    }

    // 3. 解析用户
    String userName = TokenUtil.findKey(token);
    User user = userService.findUserByUserName(userName);
    if(user == null){
        throw new BizException("用户不存在");
    }

    // 4. 校验设备
    if(
         !clientUniqueId.equals(user.getWebClientId())
    ){
        return Result.fail(401,"账号已在其他设备登录,请重新登录");
    }

    // 存入当前用户
    request.setAttribute("curUser",user);
    return true;
}

六、整体流程闭环

  1. 第一次打开网页 → 前端生成唯一 ID 存 localStorage
  2. 登录 → 后端绑定 web_client_id + 生成新 token
  3. 同账号换电脑 / 换浏览器 登录:
    • 后端对比设备 ID 不一致
    • WebSocket 给旧设备发 KICK_OUT
    • 旧页面立刻弹窗、清缓存、跳登录
  4. 同电脑重启浏览器:
    • 唯一 ID 不变 → 正常登录、不会被挤
  5. 网页 / App 各自一套字段,互不影响

七、补充说明

  1. 不需要定时任务、不需要过期时间
  2. 不需要后端读取任何硬件信息,完全浏览器前端控制
  3. WebSocket 断开 / 重连 不影响正常接口使用
  4. 无痕浏览器会生成新 ID,判定为新设备,合理安全

8、工具类及实体类补充

1、UserLoginDTO 登录接收参数实体

java

运行

复制代码
import lombok.Data;

@Data
public class UserLoginDTO {

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 前端浏览器唯一设备ID
     */
    private String clientUniqueId;

}

2、顺便补全你需要的 BizException(直接复制)

java

运行

复制代码
public class BizException extends RuntimeException {

    private Integer code;
    private String msg;

    public BizException(String msg) {
        super(msg);
        this.code = 400;
        this.msg = msg;
    }

    public BizException(Integer code, String msg) {
        super(msg);
        this.code = code;
        this.msg = msg;
    }

    public Integer getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }
}

3、全局异常处理器 GlobalExceptionHandler(配套使用)

java

运行

复制代码
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    public Result<?> bizExceptionHandler(BizException e){
        return Result.fail(e.getCode(), e.getMsg());
    }

    @ExceptionHandler(Exception.class)
    public Result<?> exceptionHandler(Exception e){
        e.printStackTrace();
        return Result.fail(500, "系统异常,请稍后重试");
    }
}

4.前端 JS 一行生成浏览器唯一 ID(直接复制)

js

复制代码
// 没有就生成,有就读取
let clientUniqueId = localStorage.getItem("clientUniqueId");
if (!clientUniqueId) {
  // 随机唯一指纹
  clientUniqueId = uuidv4() + "_" + Math.random().toString(36).slice(2);
  localStorage.setItem("clientUniqueId", clientUniqueId);
}

// 通用uuid方法
function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0;
    const v = c === 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}
相关推荐
S1998_1997111609•X1 小时前
login:/-system.web,dex.dmp,b-scode:app·%
网络·数据库·百度·facebook·twitter
仍然.1 小时前
初识计算机网络
网络·计算机网络
小程同学>o<2 小时前
Linux 应用层开发入门(二十五)| 网络编程
linux·网络·嵌入式软件·嵌入式应用层·应用层开发·linux应用层开发
忡黑梨2 小时前
eNSP_DHCP配置
c语言·网络·c++·python·算法·网络安全·智能路由器
BING_Algorithm2 小时前
Java开发常用网络协议解析
后端·网络协议
YaBingSec2 小时前
玄机网络安全靶场:Jackson-databind 反序列化漏洞(CVE-2017-7525)
linux·网络·笔记·安全·web安全
TechWayfarer2 小时前
网络安全溯源实战:78.1%网络攻击来自境外,如何精准定位攻击源
网络·安全·web安全
ElevenS_it1883 小时前
日志在哪里找?分布式环境下日志采集断裂的5个排查路径
运维·网络·分布式
半壶清水3 小时前
ubuntu中部署开源交换机模拟器bmv2详细步骤
linux·运维·网络·网络协议·tcp/ip·ubuntu