文章目录
一、背景
若依 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,可根据实际需求进行修改优化。