扫码登录 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 的映射实现精准推送。🚀

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐