用WebSocket改造优化若依在线用户实时监控

发布于:2025-02-19 ⋅ 阅读:(19) ⋅ 点赞:(0)

一、背景

若依 RuoYi-Vue 原本的在线用户监控页面是通过前端手动刷新页面或点击搜索按钮来更新数据的,不能做到实时更新,因此想用WebSocket来改造优化一下

目标效果:两个浏览器来测试,A浏览器访问在线用户页面,即建立websocket连接,可以实时收到服务器发送的消息,然后B浏览器登录,此时A浏览器页面会弹窗提示新用户上线,并且自动调用搜索接口刷新页面,实现实时更新的效果(登出同理)。且如果A浏览器切换别的页面,断开websocket连接,不再接收消息,不再弹窗。

如下图所示
在这里插入图片描述

由于若依原代码可以直接从码云RuoYi-Vue里拉,本文主要就放改好后的代码,不理解的同学可自行拉源码比对。

二、后端

1.引入依赖

pom文件

<!-- websocket-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
    <version>2.5.15</version>
</dependency>

2.定义WebSocket配置类

WebSocketConfig.java

该配置类中使用@EnableWebSocketMessageBroker注解来启用WebSocket消息代理功能,使用configureMessageBroker方法来配置消息代理的相关参数,使用registerStompEndpoints方法来注册Stomp协议的WebSocket端点。

package com.ruoyi.framework.config;

import com.ruoyi.framework.web.service.TokenService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Bean(name = "tokenService1")
    public TokenService tokenService() {
        return new TokenService();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 订阅路径前缀(前端订阅 /topic/onlineUsers)
        registry.enableSimpleBroker("/topic");
        // 发送消息时的前缀(前端发送 /app/...)
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // WebSocket 端点(前端连接 ws://localhost:8080/ws)
        registry.addEndpoint("/ws")
                .addInterceptors(new JwtHandshakeInterceptor(tokenService()))
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }
}

修改SecurityConfig.java,放开WebSocket连接地址,否则会被security拦截
在这里插入图片描述

由于我们这里放开了ws/路径,所有人都能访问,所以上面WebSocketConfig里我加了一个拦截器进行token验证,通过了验证才进行websocket连接

在这里插入图片描述

JwtHandshakeInterceptor.java

package com.ruoyi.framework.config;

import com.ruoyi.framework.web.service.TokenService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;

@Component
public class JwtHandshakeInterceptor implements HandshakeInterceptor {

    private final TokenService tokenService;

    private static final Logger log = LoggerFactory.getLogger(JwtHandshakeInterceptor.class);

    public JwtHandshakeInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String token = getTokenFromRequest(request);
        if (token == null || !validateToken(token)) {
            return false; // 认证失败,拒绝 WebSocket 连接
        }
        attributes.put("token", token);
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {}

    private String getTokenFromRequest(ServerHttpRequest request) {
        // ✅ 从 URL 获取 Token
        String query = request.getURI().getQuery();
        if (query != null && query.startsWith("token=")) {
            return query.substring(6);
        }
        return null;
    }

    private boolean validateToken(String token) {
        return tokenService.checkToken(token);
    }
}

TokenService.java

新增校验token方法

/**
 * 校验token
 *
 * @return 用户信息
 */
public boolean checkToken(String token)
{
    if (StringUtils.isNotEmpty(token))
    {
        try
        {
            Claims claims = parseToken(token);
            // 解析对应的权限以及用户信息
            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
            String userKey = getTokenKey(uuid);
            LoginUser user = redisCache.getCacheObject(userKey);
            return true;
        }
        catch (Exception e)
        {
            log.error("获取用户信息异常'{}'", e.getMessage());
        }
    }
    return false;
}

踩坑

上面代码里JwtHandshakeInterceptor类中我注入tokenService对象的方式是用了构造器,从而在WebSocketConfig类里.addInterceptors(new JwtHandshakeInterceptor(tokenService()))构造方法需要传递tokenService对象参数,然后这个对象还需要用@Bean的方式去注入,否则tokenService里的

加@value的那些属性无法赋值;

如下图所示,tokenService为null

在这里插入图片描述

在这里插入图片描述

3.定义WebSocket控制器

【这个是用于监听前端发送消息并做出响应,发送消息的,只做测试使用,本需求最后的实现方式不需要用到】

WebSocketUserController.java

定义一个WebSocket控制器,用于处理WebSocket消息。该控制器中使用@MessageMapping注解来定义WebSocket请求的地址,表示当客户端向该地址发送请求时,会自动调用下面的方法进行处理。【关于响应的这里使用SimpMessagingTemplate去响应,也可以用@SendTo注解来实现,具体可以上网搜下】

package com.ruoyi.web.controller.monitor;

import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Controller
public class WebSocketUserController {
    private static final Map<String, String> ONLINE_USERS = new ConcurrentHashMap<>();

    private final SimpMessagingTemplate messagingTemplate;

    public WebSocketUserController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    public static class TokenInfo {
        private String token;
        private String userName;
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") // 指定格式
        private Date loginTime;

        private String action;

        public TokenInfo() {
        }

        public TokenInfo(String token, String userName, Date loginTime, String action) {
            this.token = token;
            this.userName = userName;
            this.loginTime = loginTime;
            this.action = action;
        }

        public String getToken() {
            return token;
        }

        public void setToken(String token) {
            this.token = token;
        }

        public String getUserName() {
            return userName;
        }

        public void setUserName(String userName) {
            this.userName = userName;
        }

        public Date getLoginTime() {
            return loginTime;
        }

        public void setLoginTime(Date loginTime) {
            this.loginTime = loginTime;
        }

        public String getAction() {
            return action;
        }

        public void setAction(String action) {
            this.action = action;
        }
    }


    /**
     * 用户上线通知
     */
    @MessageMapping("/online")
    public void userOnline(String json) {
        TokenInfo tokenInfo = JSON.parseObject(json, TokenInfo.class);
        ONLINE_USERS.put(tokenInfo.getUserName(), "在线");

        // 推送用户上下线消息
        messagingTemplate.convertAndSend("/topic/onlineMsg", tokenInfo);
    }

    /**
     * 用户下线通知(可在前端页面关闭时调用)
     */
    @MessageMapping("/offline")
    public void userOffline(String json) {
        TokenInfo tokenInfo = JSON.parseObject(json, TokenInfo.class);
        ONLINE_USERS.remove(tokenInfo.getUserName());

        // 推送用户上下线消息
        messagingTemplate.convertAndSend("/topic/onlineMsg", tokenInfo);
    }


}

4.修改登录接口

SysLoginController.java

登录成功后,发送websocket消息

@Autowired
private SimpMessagingTemplate messagingTemplate;

/**
 * 登录方法
 * 
 * @param loginBody 登录信息
 * @return 结果
 */
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
    AjaxResult ajax = AjaxResult.success();
    // 生成令牌
    String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
            loginBody.getUuid());
    ajax.put(Constants.TOKEN, token);

    //发送websocket消息
    WebSocketUserController.TokenInfo tokenInfo = new WebSocketUserController.TokenInfo(token, loginBody.getUsername(), new Date(),
            "上线");
    // 推送用户上下线消息
    messagingTemplate.convertAndSend("/topic/onlineMsg", tokenInfo);
    return ajax;
}

5.修改退出处理类

LogoutSuccessHandlerImpl.java

同上

package com.ruoyi.framework.security.handle;

import java.io.IOException;
import java.util.Date;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.web.service.TokenService;

/**
 * 自定义退出处理类 返回成功
 * 
 * @author ruoyi
 */
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{

    public static class TokenInfo {
        private String token;
        private String userName;
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") // 指定格式
        private Date loginTime;

        private String action;

        public TokenInfo() {
        }

        public TokenInfo(String token, String userName, Date loginTime, String action) {
            this.token = token;
            this.userName = userName;
            this.loginTime = loginTime;
            this.action = action;
        }

        public String getToken() {
            return token;
        }

        public void setToken(String token) {
            this.token = token;
        }

        public String getUserName() {
            return userName;
        }

        public void setUserName(String userName) {
            this.userName = userName;
        }

        public Date getLoginTime() {
            return loginTime;
        }

        public void setLoginTime(Date loginTime) {
            this.loginTime = loginTime;
        }

        public String getAction() {
            return action;
        }

        public void setAction(String action) {
            this.action = action;
        }
    }
    @Autowired
    private TokenService tokenService;

    @Autowired
    private SimpMessagingTemplate messagingTemplate;

    /**
     * 退出处理
     * 
     * @return
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser))
        {

            String userName = loginUser.getUsername();
            String token = loginUser.getToken();

            //发送websocket消息
            TokenInfo tokenInfo = new TokenInfo(token, userName, new Date(),
                    "下线");
            // 推送用户上下线消息
            messagingTemplate.convertAndSend("/topic/onlineMsg", tokenInfo);

            // 删除用户缓存记录
            tokenService.delLoginUser(loginUser.getToken());
            // 记录用户退出日志
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, MessageUtils.message("user.logout.success")));
        }
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success(MessageUtils.message("user.logout.success"))));
    }
}

三、前端

前端的逻辑是访问到 在线用户页面的时候,建立websocket连接,订阅相关地址的消息,当接收到消息时,弹窗,并且调用搜索接口;当离开在线用户页面,则断开websocket连接。

1.安装依赖

这里我们使用了SockJS和Stomp.js来实现WebSocket通信。

npm install sockjs-client stompjs

2.新增websocket.js

src\utils\websocket.js

import SockJS from 'sockjs-client';
import Stomp from 'stompjs';
import { Message } from 'element-ui';

let stompClient = null;

export function connectWebSocket(token, username, messageCallback) {
    if (stompClient && stompClient.connected) {
        console.log("WebSocket 已连接");
        return;
    }

    // ✅ 使用 SockJS 连接 WebSocket
    const socket = new SockJS(`http://localhost:8080/ws?token=${token}`);

    stompClient = Stomp.over(socket);
    stompClient.connect(
        { Authorization: `Bearer ${token}` }, // ✅ 通过 STOMP 头传递 Token
        () => {
            console.log("WebSocket 连接成功");

            // 订阅用户上线/下线
            stompClient.subscribe("/topic/onlineMsg", (message) => {
                const data = JSON.parse(message.body);
                console.log("用户: ", data.userName,data.action);
                // ✅ 如果有用户上线/下线,显示弹窗
                showToast(`用户 ${data.userName} ${data.action}`);
                // ✅ 触发 Vue 组件中的 handleQuery 方法
                if (messageCallback) {
                    messageCallback();
                }
            });

            // // 发送用户上线通知
            // const tokenInfo = {
            //     token: token,
            //     userName: username,
            //     loginTime: new Date().getTime(),
            //     action: "上线"
            // };
            // stompClient.send("/app/online", {}, JSON.stringify(tokenInfo));
        },
        (error) => {
            console.error("WebSocket 连接失败: ", error);
            setTimeout(() => connectWebSocket(token, username, messageCallback), 5000); // 断线自动重连
        }
    );

    
}

/**
 * 显示弹窗提示
 */
function showToast(message) {
    console.log("【通知】", message);
    Message({
        message: "【通知】 " + message,
        type: 'info', // 可选值:success / warning / error
        duration: 3000 // 显示 3 秒后消失
    });
}

/**
 * 断开 WebSocket
 */
export function disconnectWebSocket(username) {
    if (stompClient !== null) {
        // // 发送用户下线通知
        // const tokenInfo = {
        //     token: token,
        //     userName: username,
        //     loginTime: new Date().getTime(),
        //     action: "下线"
        // };
        // stompClient.send("/app/offline", {}, JSON.stringify(tokenInfo));
        stompClient.disconnect();
        console.log(username, "WebSocket 已断开");
    }
}

3.修改user.js

src\store\modules\user.js

import { login, logout, getInfo } from '@/api/login'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { connectWebSocket, disconnectWebSocket } from "@/utils/websocket";

const user = {
  state: {
    token: getToken(),
    id: '',
    name: '',
    avatar: '',
    roles: [],
    permissions: []
  },

  mutations: {
    SET_TOKEN: (state, token) => {
      state.token = token
    },
    SET_ID: (state, id) => {
      state.id = id
    },
    SET_NAME: (state, name) => {
      state.name = name
    },
    SET_AVATAR: (state, avatar) => {
      state.avatar = avatar
    },
    SET_ROLES: (state, roles) => {
      state.roles = roles
    },
    SET_PERMISSIONS: (state, permissions) => {
      state.permissions = permissions
    }
  },

  actions: {
    // 登录
    Login({ commit }, userInfo) {
      const username = userInfo.username.trim()
      const password = userInfo.password
      const code = userInfo.code
      const uuid = userInfo.uuid
      return new Promise((resolve, reject) => {
        login(username, password, code, uuid).then(res => {
          // 保存 Token
          const token = res.token;
          //alert(token);
          setToken(token)
          commit('SET_TOKEN', token)
          commit('SET_NAME', username)
          // **建立 WebSocket 连接**
          // connectWebSocket(token, username);
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },


    // 获取用户信息
    GetInfo({ commit, state }) {
      return new Promise((resolve, reject) => {
        getInfo().then(res => {
          const user = res.user
          const avatar = (user.avatar == "" || user.avatar == null) ? require("@/assets/images/profile.jpg") : process.env.VUE_APP_BASE_API + user.avatar;
          if (res.roles && res.roles.length > 0) { // 验证返回的roles是否是一个非空数组
            commit('SET_ROLES', res.roles)
            commit('SET_PERMISSIONS', res.permissions)
          } else {
            commit('SET_ROLES', ['ROLE_DEFAULT'])
          }
          commit('SET_ID', user.userId)
          commit('SET_NAME', user.userName)
          commit('SET_AVATAR', avatar)
          resolve(res)
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 退出系统
    LogOut({ commit, state }) {
      return new Promise((resolve, reject) => {
        logout(state.token).then(() => {
          // **断开 WebSocket 连接**
          disconnectWebSocket(state.name);
          commit('SET_TOKEN', '')
          commit('SET_NAME', '')
          commit('SET_ROLES', [])
          commit('SET_PERMISSIONS', [])
          removeToken()
          resolve()
        }).catch(error => {
          reject(error)
        })
      })
    },

    // 前端 登出
    FedLogOut({ commit, state  }) {
      return new Promise(resolve => {
        // **断开 WebSocket 连接**
        disconnectWebSocket(state.name);
        removeToken()
        commit('SET_TOKEN', '')
        commit('SET_NAME', '')
        resolve()
      })
    }
  }
}

export default user

4.修改在线用户页面

src\views\monitor\online\index.vue

<template>
  <div class="app-container">
    <el-form :model="queryParams" ref="queryForm" size="small" :inline="true" label-width="68px">
      <el-form-item label="登录地址" prop="ipaddr">
        <el-input
          v-model="queryParams.ipaddr"
          placeholder="请输入登录地址"
          clearable
          @keyup.enter.native="handleQuery"
        />
      </el-form-item>
      <el-form-item label="用户名称" prop="userName">
        <el-input
          v-model="queryParams.userName"
          placeholder="请输入用户名称"
          clearable
          @keyup.enter.native="handleQuery"
        />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
        <el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
      </el-form-item>

    </el-form>
    <el-table
      v-loading="loading"
      :data="list.slice((pageNum-1)*pageSize,pageNum*pageSize)"
      style="width: 100%;"
    >
      <el-table-column label="序号" type="index" align="center">
        <template slot-scope="scope">
          <span>{{(pageNum - 1) * pageSize + scope.$index + 1}}</span>
        </template>
      </el-table-column>
      <el-table-column label="会话编号" align="center" prop="tokenId" :show-overflow-tooltip="true" />
      <el-table-column label="登录名称" align="center" prop="userName" :show-overflow-tooltip="true" />
      <el-table-column label="部门名称" align="center" prop="deptName" />
      <el-table-column label="主机" align="center" prop="ipaddr" :show-overflow-tooltip="true" />
      <el-table-column label="登录地点" align="center" prop="loginLocation" :show-overflow-tooltip="true" />
      <el-table-column label="浏览器" align="center" prop="browser" />
      <el-table-column label="操作系统" align="center" prop="os" />
      <el-table-column label="登录时间" align="center" prop="loginTime" width="180">
        <template slot-scope="scope">
          <span>{{ parseTime(scope.row.loginTime) }}</span>
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
        <template slot-scope="scope">
          <el-button
            size="mini"
            type="text"
            icon="el-icon-delete"
            @click="handleForceLogout(scope.row)"
            v-hasPermi="['monitor:online:forceLogout']"
          >强退</el-button>
        </template>
      </el-table-column>
    </el-table>

    <pagination v-show="total>0" :total="total" :page.sync="pageNum" :limit.sync="pageSize" />
  </div>
</template>

<script>
import { list, forceLogout } from "@/api/monitor/online";
import { connectWebSocket, disconnectWebSocket } from "@/utils/websocket";
import { watch } from 'vue';
import { useRoute } from 'vue-router';


export default {
  name: "Online",
  data() {
    return {
      // 遮罩层
      loading: true,
      // 总条数
      total: 0,
      // 表格数据
      list: [],
      pageNum: 1,
      pageSize: 10,
      // 查询参数
      queryParams: {
        ipaddr: undefined,
        userName: undefined
      }
    };
  },
  created() {
    this.getList();
  },
  mounted() {
    const token = this.$store.state.user.token;  // Vuex 里获取 token
    
    const username = this.$store.state.user.name;  // Vuex 里获取用户名
    console.log("token ", token, "; username ", username, " 上线");
    if (token && username) {
      connectWebSocket(token, username, this.handleQuery);
    }
  },
  beforeRouteLeave(to, from, next) {
    const token = this.$store.state.user.token;
    const username = this.$store.state.user.name;
    disconnectWebSocket(username); // 断开 WebSocket 连接
    next(); // 继续导航
  },
  methods: {
    /** 查询登录日志列表 */
    getList() {
      this.loading = true;
      list(this.queryParams).then(response => {
        this.list = response.rows;
        this.total = response.total;
        this.loading = false;
      });
    },
    /** 搜索按钮操作 */
    handleQuery() {
      this.pageNum = 1;
      this.getList();
    },
    /** 重置按钮操作 */
    resetQuery() {
      this.resetForm("queryForm");
      this.handleQuery();
    },
    /** 强退按钮操作 */
    handleForceLogout(row) {
      this.$modal.confirm('是否确认强退名称为"' + row.userName + '"的用户?').then(function() {
        return forceLogout(row.tokenId);
      }).then(() => {
        this.getList();
        this.$modal.msgSuccess("强退成功");
      }).catch(() => {});
    }
  }
};
</script>


注意connectWebSocket(token, username, this.handleQuery);这里传递了搜索方法handleQuery作为回调方法,后面websocket订阅消息,监听到消息的时候才能进行回调,从而更新页面

在这里插入图片描述

到这里就完成了实时更新在线用户列表的效果了,本文只是做一个demo,可根据实际需求进行修改优化。


网站公告

今日签到

点亮在社区的每一天
去签到