WebSocket 使用指南
WebSocket 是 sz-admin 中的可选实时通信能力,主要用于在线通知、字典刷新、前端配置刷新、权限同步、强制下线和站内消息等场景。
设计理念
NOTE
WebSocket 在框架中被设计为一项轻量的基础通信能力,而不是承载全部业务规则的独立业务中心。sz-service-websocket 负责连接管理、鉴权、心跳和消息转发;Admin 业务端继续负责具体业务判断和消息内容组织。
为了让 WebSocket 服务与 Admin 业务端保持解耦,框架使用 Redis Pub/Sub 作为服务间消息通道。业务端只需要发布标准化的推送消息,WebSocket 服务订阅后再分发给在线客户端。这样既避免了服务之间的直接强依赖,也更适合多实例部署时的消息转发和节点协同。
关键要点:
sz-service-websocket只提供基础 WebSocket 通讯能力,不直接绑定具体业务流程。- Admin 业务端通过
SocketService或WebsocketRedisService发布消息,业务含义由业务端决定。 - Redis Pub/Sub 负责连接两个边界:业务服务不需要感知具体在线连接,WebSocket 服务也不需要侵入业务模块。
- 扩展实时通信能力时,可以在保持整体架构轻量的前提下,自定义频道、消息内容和前端处理逻辑。
实际使用时可以先按下面的顺序理解:
- 前端配置
VITE_SOCKET_URL,登录后自动连接 WebSocket。 - 后端业务代码优先调用
SocketService推送消息。 - 前端通过内置频道处理器或
mittBus监听业务频道。 - 需要扩展时,再新增频道枚举、后端推送逻辑和前端处理逻辑。
一、快速启用
1. 启动 WebSocket 服务
后端 WebSocket 能力由独立服务 sz-service-websocket 提供,默认端口为 9993,连接端点为 /socket。
server:
port: 9993
spring:
application:
name: websocket-service该服务会加载 Redis 和 Sa-Token 配置,用于校验登录态、维护在线连接和进行服务间消息转发。因此 WebSocket 服务需要与业务服务使用同一套登录态相关 Redis 数据。
2. 配置前端连接地址
前端通过 VITE_SOCKET_URL 控制是否启用 WebSocket。该环境变量不存在或为空时,前端不会创建 WebSocket 连接。
VITE_SOCKET_URL=ws://127.0.0.1:9993/socket如果生产环境使用 HTTPS,应使用 wss://。前端代码会根据当前协议把相对地址转换为 ws: 或 wss:。
3. 前端自动建立连接
前端布局加载时会调用 useSocketStore().open(),当前用户存在 token 且 VITE_SOCKET_URL 已配置时,会创建连接。
const ws = new WebSocket(socketUrl, [userStore.token]);由于浏览器原生 WebSocket API 不支持自定义请求头,token 会通过 Sec-WebSocket-Protocol 子协议头传递给后端。
二、后端如何推送消息
业务代码优先使用 SocketService。它位于 sz-module-admin,对外统一接收 Long 类型用户 ID,内部负责转换为 WebSocket 链路使用的 loginId 字符串。
// 全员同步前端配置
socketService.syncFrontendConfig();
// 全员同步字典
socketService.syncDict();
// 定向同步用户权限
socketService.syncPermission(userId);
// 强制指定用户下线
socketService.kickOff(userId);
// 发送站内消息
socketService.sendMessage(body, senderId, receiverIds);
// 通知消息已读
socketService.readMessage(fromUserId, toUsers);如果是框架级或特殊场景,需要绕过业务封装,也可以直接使用 WebsocketRedisService 和 SocketUtil。
// 全员广播
websocketRedisService.sendServiceToWs(
SocketUtil.broadcast(SocketPushMessage.of(SocketChannelEnum.SYNC_DICT))
);
// 定向推送
websocketRedisService.sendServiceToWs(
SocketUtil.toUsers(
SocketPushMessage.of(SocketChannelEnum.UPGRADE_CHANNEL, "系统即将升级"),
List.of("1"),
"system"
)
);普通业务优先使用 SocketService,只有在需要自定义底层路由、跨模块封装或演示接口时,再直接使用 WebsocketRedisService。
三、前端如何接收消息
前端消息入口在 src/stores/modules/socket/socket.ts,消息解析后会交给 channelHandlers.ts 分发。
内置频道会直接执行对应逻辑:
| 频道 | 前端行为 |
|---|---|
PONG | 清除心跳超时计时器 |
KICK_OFF | 关闭连接、清理登录态并跳转登录页 |
SYNC_FRONTEND_CONF | 标记并重新拉取前端配置 |
SYNC_DICT | 标记字典缓存失效并重新预热字典 |
SYNC_PERMISSIONS | 重新获取按钮权限 |
UPGRADE_CHANNEL | 通过 mittBus 广播 socket.UPGRADE_CHANNEL |
MESSAGE | 通过 mittBus 广播 socket.MESSAGE |
READ | 通过 mittBus 广播 socket.READ |
未显式注册的频道不会丢弃,会统一按 socket.${channel} 广播。
mittBus.on('socket.UPGRADE_CHANNEL', data => {
// 处理系统升级通知
});站内消息组件就是通过该机制监听消息变化:
mittBus.on('socket.MESSAGE', handleMessage);
mittBus.on('socket.READ', () => {
getUnreadCount();
});四、扩展一个新频道
新增业务频道时,推荐按三个位置修改:
- 后端在
SocketChannelEnum中新增枚举值。 - 后端业务侧通过
SocketService或SocketUtil推送该频道。 - 前端在
channelHandlers.ts中增加处理器,或在页面中监听mittBus的socket.${channel}事件。
后端推送给前端时,SocketPushMessage.of(...) 使用的是枚举常量名,例如 SocketChannelEnum.SYNC_DICT 最终发送给前端的频道是 SYNC_DICT。
{
"channel": "SYNC_DICT",
"data": null
}因此前后端频道名称应以枚举常量名和前端常量为准,不使用枚举里的 name 字段作为传输值。
五、内置业务场景
| 场景 | 后端触发 | 推送频道 | 前端处理 |
|---|---|---|---|
| 前端配置变更 | socketService.syncFrontendConfig() | SYNC_FRONTEND_CONF | 重新拉取前端配置 |
| 字典、字典类型、字典来源变更 | socketService.syncDict() | SYNC_DICT | 标记字典缓存失效并重新加载 |
| 用户权限变化 | socketService.syncPermission(userId) | SYNC_PERMISSIONS | 重新获取权限按钮 |
| 强制下线 | socketService.kickOff(userId) | KICK_OFF | 清理登录态并跳转登录页 |
| 站内消息发送 | socketService.sendMessage(...) | MESSAGE | 弹出通知并刷新未读数量 |
| 站内消息已读 | socketService.readMessage(...) | READ | 刷新未读数量 |
| 升级公告演示 | SocketChannelEnum.UPGRADE_CHANNEL | UPGRADE_CHANNEL | 通过 mittBus 交给业务接管 |
测试演示接口位于 TestController,请求前缀为 /www,仅在 dev、local、preview 环境激活。生产环境不要依赖这些演示接口。
POST /www/push/all
POST /www/push/user
POST /www/kick
POST /www/message/send六、消息结构
框架中有三类消息对象,各自负责不同边界。
| 类型 | 方向 | 说明 |
|---|---|---|
ClientMessage | 前端到 WebSocket 服务 | 接收客户端消息,channel 为字符串,可兼容 PING 等非枚举频道 |
SocketPushMessage | 服务端到前端 | 最终推送给浏览器的消息体,channel 为 SocketChannelEnum.name() |
TransferMessage | 后端服务间 | Redis Pub/Sub 中传递的信封,包含接收人、是否广播、消息范围等路由信息 |
前端发送心跳:
{
"channel": "PING"
}后端返回心跳:
{
"channel": "PONG"
}业务推送消息:
{
"channel": "UPGRADE_CHANNEL",
"data": "系统即将升级"
}data 可以是字符串、对象或数组,前端解析后会直接放入 msg.data,不需要再次手动 JSON.parse,除非某个业务字段本身就是 JSON 字符串。
七、鉴权与会话失效
WebSocket 建连时,后端 WebSocketInterceptor 会从 Sec-WebSocket-Protocol 中解析 token,并执行以下校验:
- 校验 JWT 签名和 loginType。
- 从 JWT payload 中读取
loginId。 - 校验 token-session 是否仍存在于 Redis。
- 校验账号是否被禁用。
- 刷新 Sa-Token 的 active-timeout。
- 将
loginId和 token 写入 WebSocket session attributes。 - 将 token 回写到
Sec-WebSocket-Protocol响应头,完成浏览器子协议确认。
连接建立后,非心跳消息进入业务处理前还会再次校验 token。为减少 Redis 写入压力,updateLastActiveToNow 在非心跳消息中按 5 分钟节流刷新;签名校验、Redis session 存在性和禁用状态判断仍会执行。
如果鉴权失效,服务端会用自定义关闭码 4401 关闭连接。前端识别该关闭码后会停止重连,并复用统一登录失效处理逻辑清理登录态、跳转登录页。
if (event.code === CLOSE_CODE_AUTH_EXPIRED) {
canReconnect.value = false;
handleAuthExpired();
return;
}此外,WebSocketSessionGuard 会每 60 秒扫描当前节点的在线 session。如果发现 token 已注销、被踢、被替换、active-timeout 失效或账号被禁用,也会主动用 4401 断开连接。
八、心跳与重连
前端连接建立后会启动心跳:
- 每 30 秒发送一次
{ "channel": "PING" }。 - 发送 PING 后等待 10 秒 PONG,超时则认为连接异常并触发重连。
- 页面从后台切回前台时,如果连接不是 OPEN 状态,会立即尝试重新连接。
- 只有服务端返回
4401时才停止重连并进入登录失效流程。
后端对 PING 走快速路径:不进入完整业务分发,只刷新 active-timeout 并立即返回 PONG。
PING 和 PONG 是心跳信令,不在 SocketChannelEnum 中维护,前后端都使用字符串常量处理。
九、CORS 与 Nginx
WebSocket 跨域由 sz-service-websocket 的 sz.cors.allowed-origins 控制。
sz:
cors:
allowed-origins: "*"本地开发可以使用 *。生产环境应显式配置前端实际访问域名,多个来源用英文逗号分隔。
sz:
cors:
allowed-origins: "https://admin.example.com,https://preview.example.com"这里配置的是浏览器页面的真实 Origin,不是 Nginx 地址,也不是后端服务地址。
Nginx 反向代理 WebSocket 时,需要保留协议升级头。
location /socket {
proxy_pass http://sz-service-websocket:9993;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}十、服务间通信原理
WebSocket 服务只负责连接管理、鉴权、心跳和消息转发;具体业务逻辑仍在 Admin 业务服务中。
后端服务间通信依赖 Redis Pub/Sub:
| Redis channel | 方向 | 说明 |
|---|---|---|
channel:service_to_ws | 业务服务到 WebSocket 服务 | 业务服务发布 TransferMessage,WebSocket 服务订阅后推送给浏览器 |
channel:ws_to_service | WebSocket 服务到业务服务 | 浏览器上行消息经 WebSocket 服务透传给业务服务 |
服务端推送浏览器的主链路如下:
flowchart LR
A["业务代码"] --> B["SocketService / WebsocketRedisService"]
B --> C["Redis: channel:service_to_ws"]
C --> D["sz-service-websocket"]
D --> E["浏览器 WebSocket 连接"]浏览器上行消息的链路如下:
flowchart LR
A["浏览器"] --> B["sz-service-websocket"]
B --> C["Redis: channel:ws_to_service"]
C --> D["业务服务监听器"]如果只是做服务端通知前端,大多数场景只需要使用 SocketService,不需要关心 Redis 信封细节。
十一、排查清单
连接没有建立时,优先检查:
VITE_SOCKET_URL是否存在且非空。sz-service-websocket是否已启动,端口是否为9993。- 前端 token 是否存在。
- WebSocket 服务是否能访问同一套 Sa-Token Redis 数据。
sz.cors.allowed-origins是否包含当前前端页面的真实 Origin。- Nginx 是否正确配置
Upgrade和Connection头。
连接后收不到消息时,优先检查:
- 后端是否调用了
SocketService或sendServiceToWs。 redis.listener.enable是否开启,Redis Pub/Sub 监听是否生效。- 定向推送的
toUsers是否为登录用户的loginId字符串。 - 后端发送的
channel是否与前端常量一致。 - 前端是否在
channelHandlers.ts或mittBus中处理了该频道。
频繁掉线或反复重连时,优先检查:
- 服务端是否返回了
4401,如果是,说明登录态已失效或账号状态不允许继续连接。 - 浏览器是否能在 10 秒内收到
PONG。 - 代理层是否存在较短的 WebSocket 读写超时。
- WebSocket 服务、业务服务和登录服务是否连接到同一 Redis 环境。
