spring websocket实现扫码登录
扫码登录 WebSocket Session 流程和知识点。
📊 完整流程梳理
1. 前端建立 WebSocket 连接
// 前端生成唯一 scanCode
const scanCode = generateUUID(); // 例如: "abc-123-xyz"
// 建立 WebSocket 连接(无 Token)
const ws = new WebSocket('ws://localhost:48080/system/ws?scanCode=abc-123-xyz');
ws.onopen = () => {
// 发送注册消息
ws.send(JSON.stringify({
type: 'scan-login-register',
content: JSON.stringify({ scanCode: 'abc-123-xyz' })
}));
};
2. 后端处理连接建立
浏览器 → Gateway (48080) → system-server (48081)
步骤 2.1:握手拦截器提取 scanCode
[LoginUserHandshakeInterceptor.java](file:///F:/JavaProgram/cloud/jlk-framework/jlk-spring-boot-starter-websocket/src/main/java/cn/teaching/jlk/framework/websocket/core/security/LoginUserHandshakeInterceptor.java#L26-L40)
@Override
public boolean beforeHandshake(ServerHttpRequest request, ...) {
// 提取 scanCode 参数
String scanCode = extractScanCode(request);
if (scanCode != null) {
attributes.put("SCAN_CODE", scanCode);
}
return true;
}
步骤 2.2:自动注册 Session
[WebSocketSessionHandlerDecorator.java](file:///F:/JavaProgram/cloud/jlk-framework/jlk-spring-boot-starter-websocket/src/main/java/cn/teaching/jlk/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java#L37-L42)
@Override
public void afterConnectionEstablished(WebSocketSession session) {
// 包装为支持并发的 Session
session = new ConcurrentWebSocketSessionDecorator(session, ...);
// ⭐ 自动添加到 WebSocketSessionManager
sessionManager.addSession(session);
}
此时 WebSocketSessionManager 中存储:
idSessions: {
"bc89a7af-430a-fcbb-065a-a4f7bdf110a4" → WebSocketSession对象
}
3. 前端发送注册消息
ws.send(JSON.stringify({
type: 'scan-login-register',
content: JSON.stringify({ scanCode: 'abc-123-xyz' })
}));
4. 后端处理注册消息
[ScanLoginWebSocketMessageListener.java](file:///F:/JavaProgram/cloud/jlk-module-system/jlk-module-system-server/src/main/java/cn/teaching/jlk/module/system/websocket/ScanLoginWebSocketMessageListener.java#L30-L45)
@Override
public void onMessage(WebSocketSession session, ScanLoginRegisterMessage message) {
String scanCode = message.getScanCode();
String sessionId = session.getId(); // "bc89a7af-..."
// ⭐ 关键:将 scanCode 与 sessionId 映射存储到 Redis
scanLoginService.registerScanCode(scanCode, sessionId);
// 返回确认消息
webSocketMessageSender.sendObject(sessionId, "scan-login-register-response", response);
}
此时 Redis 中存储:
Key: scan_login:abc-123-xyz
Value: bc89a7af-430a-fcbb-065a-a4f7bdf110a4
TTL: 300秒(5分钟)
5. APP 扫码确认
POST /system/auth/scan-login/confirm
{
"scanCode": "abc-123-xyz",
"userId": 123
}
[ScanLoginServiceImpl.java](file:///F:/JavaProgram/cloud/jlk-module-system/jlk-module-system-server/src/main/java/cn/teaching/jlk/module/system/service/auth/ScanLoginServiceImpl.java#L58-L88)
public CommonResult<Boolean> confirmScanLogin(String scanCode, Long userId) {
// 1. 从 Redis 获取 sessionId
String sessionId = redisTemplate.opsForValue().get("scan_login:abc-123-xyz");
// sessionId = "bc89a7af-430a-fcbb-065a-a4f7bdf110a4"
// 2. 生成 Token
OAuth2AccessTokenDO token = oauth2TokenService.createAccessToken(...);
// 3. ⭐ 通过 sessionId 查找 WebSocket Session 并推送
webSocketMessageSender.sendObject(sessionId, "scan-login-success", response);
// 4. 删除 Redis 数据(防止重复使用)
redisTemplate.delete("scan_login:abc-123-xyz");
}
6. 前端收到登录信息
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'scan-login-success') {
const loginData = JSON.parse(msg.content);
// 保存 Token
localStorage.setItem('token', loginData.accessToken);
// 跳转到首页
router.push('/home');
}
};
🔑 WebSocket Session 核心知识点
1. Session 的生命周期
创建 → 注册 → 使用 → 销毁
| 阶段 | 触发时机 | 操作 |
|---|---|---|
| 创建 | 前端 new WebSocket() | 浏览器发起 TCP + HTTP Upgrade |
| 注册 | afterConnectionEstablished | 自动添加到 WebSocketSessionManager |
| 使用 | 接收/发送消息 | 通过 sessionId 查找 Session |
| 销毁 | 连接关闭/超时 | 自动从 WebSocketSessionManager 移除 |
2. Session 存储结构
[WebSocketSessionManagerImpl.java](file:///F:/JavaProgram/cloud/jlk-framework/jlk-spring-boot-starter-websocket/src/main/java/cn/teaching/jlk/framework/websocket/core/session/WebSocketSessionManagerImpl.java#L31-L40)
// 1. 按 sessionId 存储
ConcurrentMap<String, WebSocketSession> idSessions = new ConcurrentHashMap<>();
// 2. 按用户类型 + 用户ID 存储(登录后才有)
ConcurrentMap<Integer, ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>>> userSessions;
扫码登录时的特殊情况:
// 未登录时,user = null,只存储在 idSessions 中
[addSession][sessionId=xxx, userId=null, userType=null, tenantId=null]
3. Session ID 的生成
sessionId = UUID 格式
例如: "bc89a7af-430a-fcbb-065a-a4f7bdf110a4"
- 由 Spring WebSocket 框架自动生成
- 每个连接都有唯一的 sessionId
- 用于在
WebSocketSessionManager中查找 Session
4. Session 的作用域
⚠️ 重要:Session 是服务实例级别的!
system-server 实例1 (48081)
└─ WebSocketSessionManager
└─ idSessions: { "session-1" → ..., "session-2" → ... }
infra-server 实例1 (48082)
└─ WebSocketSessionManager
└─ idSessions: { "session-3" → ..., "session-4" → ... }
这就是为什么之前跨服务调用会失败的原因!
🎯 回答你的问题
Q1: 这是单通道吗?
答:不是单通道,是多通道!
用户A 浏览器 → ws://.../system/ws?scanCode=A-xxx
→ Session-A (sessionId: abc-123)
用户B 浏览器 → ws://.../system/ws?scanCode=B-yyy
→ Session-B (sessionId: def-456)
用户C 浏览器 → ws://.../system/ws?scanCode=C-zzz
→ Session-C (sessionId: ghi-789)
每个用户都会建立一个独立的 WebSocket 连接(通道)!
Q2: 每个用户进来扫码都会建立一个全新的通道吗?
答:是的!完全正确!
时间线:
T1: 用户A 打开网页 → 建立 Session-A → 显示二维码
T2: 用户B 打开网页 → 建立 Session-B → 显示二维码
T3: 用户C 打开网页 → 建立 Session-C → 显示二维码
T4: 用户A 扫码 → 通过 Session-A 推送登录信息 ✅
T5: 用户B 扫码 → 通过 Session-B 推送登录信息 ✅
T6: 用户C 扫码 → 通过 Session-C 推送登录信息 ✅
关键点:
- 每个浏览器标签页 = 一个独立的 WebSocket 连接
- 每个连接有唯一的
sessionId - 通过
scanCode → sessionId的映射,确保消息推送到正确的用户
Q3: Session 什么时候销毁?
// 情况1:用户主动关闭页面
window.onbeforeunload = () => {
ws.close(); // Session 被移除
};
// 情况2:网络断开
ws.onclose = (event) => {
console.log('连接关闭:', event.code);
// Session 被移除
};
// 情况3:长时间无心跳(框架默认配置)
// 通常 60-300 秒后自动断开
后端自动清理:
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
sessionManager.removeSession(session); // ⭐ 自动移除
}
📈 并发场景分析
场景1:同一用户多个标签页
用户A - 标签页1 → Session-A1 (scanCode: A-111)
用户A - 标签页2 → Session-A2 (scanCode: A-222)
APP 扫描 A-111 → 只有标签页1 收到登录信息 ✅
APP 扫描 A-222 → 只有标签页2 收到登录信息 ✅
场景2:扫码过期
T0: 前端建立连接 → Redis 存储 scanCode → TTL=300秒
T300: 5分钟后未扫码 → Redis 自动删除
T301: APP 扫码 → Redis 查询返回 null → 提示"二维码已过期"
场景3:重复扫码
T0: APP 第一次扫码 → 推送成功 → Redis 删除 scanCode
T1: APP 第二次扫码 → Redis 查询返回 null → 拒绝重复登录 ✅
🔐 安全性设计
1. scanCode 一次性使用
// 推送成功后立即删除
stringRedisTemplate.delete(redisKey);
2. 过期时间控制
// 5分钟过期
stringRedisTemplate.opsForValue().set(redisKey, sessionId, 300, TimeUnit.SECONDS);
3. 无 Token 连接的安全性
风险:未认证的 WebSocket 连接可能被滥用
缓解措施:
✅ scanCode 随机生成(UUID),难以猜测
✅ 5分钟自动过期
✅ 一次性使用,用后即删
✅ 连接建立后需发送注册消息才生效
💡 总结
| 特性 | 说明 |
|---|---|
| 通道数量 | 多通道,每个用户一个独立连接 |
| Session 存储 | 服务本地内存(ConcurrentHashMap) |
| Session 查找 | 通过 sessionId 精确匹配 |
| 跨服务调用 | ❌ 不可行,Session 不共享 |
| 会话隔离 | ✅ 完全隔离,互不影响 |
| 并发支持 | ✅ 使用 ConcurrentWebSocketSessionDecorator |
| 自动清理 | ✅ 连接关闭时自动移除 |
这就是完整的 WebSocket Session 机制!每个用户都是独立的通道,通过 scanCode → sessionId 的映射实现精准推送。🚀
更多推荐



所有评论(0)