如何在Grails中实现并发会话过滤器处理多登录会话场景?
多端登录冲突处理实现方案
核心设计逻辑
针对同一账号多端登录的冲突,核心是会话生命周期管理+实时状态交互,实现两种场景的处理:
- 当原有会话超过3-4分钟无操作,自动清理并允许新登录
- 当原有会话处于活跃状态,通过实时通知让用户确认是否授权新登录
具体实现步骤
1. 会话与活跃状态追踪
- 每个用户登录时生成唯一
sessionId,将会话信息(用户ID、最后活跃时间、设备标识、登录IP等)存储在Redis中,结构设计为:- 哈希表
user:{userId}:sessions:存储该用户所有有效会话的sessionId与对应信息 - 字符串
session:{sessionId}:存储单个会话的详细数据,方便快速查询
- 哈希表
- 前端在用户有操作(页面跳转、点击事件、表单输入等)时,每隔30秒发送一次心跳请求,更新服务端对应
sessionId的lastActiveTime
2. 新登录请求校验流程
当收到新的登录请求时:
- 查询该用户名下所有有效会话
- 遍历会话,判断是否处于非活跃状态:计算当前时间与
lastActiveTime的差值,若超过240秒(4分钟),直接删除该会话(清理过期会话) - 如果清理后仍存在活跃会话,触发实时通知流程;否则直接创建新会话,允许新用户登录
3. 实时通知与权限确认
- 服务端通过WebSocket(或SSE)向活跃会话的前端推送登录尝试通知
- 前端收到通知后弹出确认框,展示提示语:
有用户正从其他浏览器/设备尝试登录,是否允许? - 前端将用户的选择(同意/拒绝)回传给服务端:
- 同意:删除原有活跃会话,为新登录请求创建新会话,同时向原前端发送强制登出指令
- 拒绝:直接返回错误响应给新登录请求,提示
登录被当前在线用户拒绝
关键代码示例
服务端(Node.js + Redis)会话检查逻辑
const redis = require('redis'); const client = redis.createClient(); async function handleNewLogin(userId, newDeviceInfo) { // 获取用户所有会话 const sessions = await client.hGetAll(`user:${userId}:sessions`); const now = Date.now(); const inactiveThreshold = 240 * 1000; // 4分钟阈值 let hasActiveSession = false; // 清理非活跃会话,检查是否存在活跃会话 for (const [sessionId, sessionStr] of Object.entries(sessions)) { const sessionData = JSON.parse(sessionStr); if (now - sessionData.lastActiveTime > inactiveThreshold) { // 删除过期会话 await client.hDel(`user:${userId}:sessions`, sessionId); await client.del(`session:${sessionId}`); } else { hasActiveSession = true; // 向活跃会话推送登录尝试通知 await pushLoginAttemptNotification(sessionId); } } if (!hasActiveSession) { // 创建新会话 const newSessionId = generateUniqueSessionId(); const newSessionData = { userId, lastActiveTime: now, deviceInfo: newDeviceInfo, loginTime: now }; await client.hSet(`user:${userId}:sessions`, newSessionId, JSON.stringify(newSessionData)); await client.set(`session:${newSessionId}`, JSON.stringify(newSessionData)); return { success: true, sessionId: newSessionId }; } else { // 等待用户确认,返回等待状态 return { success: false, message: '等待当前在线用户确认' }; } } // 生成唯一sessionId的工具函数 function generateUniqueSessionId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } // 推送登录尝试通知(WebSocket实现示例) async function pushLoginAttemptNotification(sessionId) { // 假设WebSocket服务维护了sessionId与连接的映射 const socket = wsConnections.get(sessionId); if (socket) { socket.send(JSON.stringify({ type: 'LOGIN_ATTEMPT' })); } }
前端心跳与WebSocket处理
// 心跳请求,每30秒更新活跃状态 setInterval(() => { const sessionId = localStorage.getItem('sessionId'); if (sessionId) { fetch('/api/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId }) }); } }, 30000); // WebSocket监听登录通知 const sessionId = localStorage.getItem('sessionId'); if (sessionId) { const socket = new WebSocket(`ws://your-domain/ws/${sessionId}`); socket.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'LOGIN_ATTEMPT') { const allow = confirm('有用户正从其他浏览器/设备尝试登录,是否允许?'); fetch('/api/confirm-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, allow }) }).then(res => res.json()).then(result => { if (result.success && allow) { // 被强制登出,清理本地存储并跳转登录页 localStorage.removeItem('sessionId'); window.location.href = '/login'; } }); } }; // 处理WebSocket重连 socket.onclose = () => { setTimeout(() => { // 重新建立连接 window.location.reload(); }, 5000); }; }
注意事项
- 会话存储优先选择Redis这类高性能内存数据库,支持原子操作与过期时间,避免并发问题
- 心跳请求可根据业务场景调整间隔,避免过于频繁增加服务器压力
- WebSocket连接需处理重连逻辑,防止网络波动导致通知丢失
- 会话操作需加分布式锁(比如Redis的SETNX),避免并发登录请求导致的状态不一致
内容的提问来源于stack exchange,提问作者Om Prakash
相关产品推荐
相关产品推荐

