WebSocket
受众:开发者 摘要:WebSocket starter:连接管理 / 用户绑定 / 设备推送 / 分组发送 / 广播 / Redisson 多实例。
WebSocket 提供实时连接、在线会话管理、点对点发送、按用户发送、按分组发送、广播和多实例分发能力。
它适合聊天室、站内实时通知、AI 任务状态推送、支付/Web3 状态提醒等场景。普通后台通知请优先看 Notification,Redis 基础能力请看 Redis。
快速使用
引入 starter:
<dependency>
<groupId>com.springopen</groupId>
<artifactId>spring-open-starter-websocket</artifactId>
</dependency>默认连接端点:
WS /api/ws?userId=10001&device=browser引入 starter 且 spring.open.websocket.enabled=true 时,框架会自动注册 Spring WebSocket Handler Mapping;开发者不需要额外添加 @EnableWebSocket 或手写 WebSocketConfigurer 才能让 /api/ws 生效。
前端不要写死 ws://localhost:8080/api/ws。接入 Site 模块时,先读取 GET /api/v1/site,使用返回的 websocketUrl、websocketEndpoint、websocketTokenParameter 和 websocketDeviceParameter 建立连接。websocketUrl 会根据站点 URL 自动推导 ws:// 或 wss://;为空时,前端用当前 location.origin 加 websocketEndpoint 拼接即可。这样 Docker / Nginx / HTTPS / 子路径部署时,实时连接地址仍由后端 contract 统一兜底。
接入 spring-open-core-websocket 和 spring-open-core-iam 后,WebSocket 会优先根据登录 Token 解析用户身份。浏览器端推荐使用 token 查询参数,服务端或原生客户端也可以使用 Authorization: Bearer <token>;如果 Header 和查询参数同时存在,Header 优先。没有 Token 时才回退到 userId 参数,适合游客、样板或内部调试场景。
如果请求携带了 Token 但 Token 无效,连接会被服务端关闭为 1008 Policy Violation,不会静默降级成游客连接。这样可以避免“握手成功但用户在线会话没有绑定,定向推送找不到接收方”的问题。
WS /api/ws?token=<access-token>&device=browser生产环境如果不希望 Token 出现在 Nginx access log 或浏览器地址中,可以关闭查询参数 Token:
spring:
open:
websocket:
allow-query-token: false关闭后,后端只从 Authorization: Bearer <token> 这类握手 Header 读取 Token。浏览器 WebSocket 原生 API 不能自定义 Header,因此 Web 端生产环境更推荐再做一层短期 WebSocket ticket,或由网关 / BFF 把鉴权结果注入握手上下文。
如果某个部署场景不允许游客连接,可以开启强登录:
spring:
open:
websocket:
require-authentication: true开启后,未解析出登录用户的连接同样会被关闭为 1008 Policy Violation。默认值为 false,保留公开聊天室、演示页和游客聊天的使用空间。
普通
GET /api/ws不是 WebSocket 握手请求,缺少Upgrade: websocket头时返回 404 或普通 HTTP 响应都不能说明 WebSocket 不可用。联调时请使用浏览器 WebSocket、wscat、websocat或前端真实连接检查101 Switching Protocols。
发送消息:
import com.springopen.starter.websocket.WebSockets;
import java.util.Collection;
import java.util.List;
WebSockets.sendToUser("10001", "{\"type\":\"notice\",\"content\":\"任务完成\"}");
WebSockets.sendToUserExceptSession("10001", "session-1", "{\"type\":\"notice\",\"content\":\"同步到当前用户其他连接\"}");
WebSockets.sendToUserDevice("10001", "web", "{\"type\":\"notice\",\"content\":\"只推送到 Web 端\"}");
WebSockets.sendToUserDevices("10001", List.of("web", "app"), "{\"type\":\"notice\",\"content\":\"推送到多个指定端\"}");
WebSockets.sendToUsers(List.of("10001", "10002"), "{\"type\":\"notice\",\"content\":\"任务完成\"}");
WebSockets.sendToUsersByDevice(List.of("10001", "10002"), "web", "{\"type\":\"notice\",\"content\":\"推送多个用户的 Web 端\"}");
WebSockets.sendToSessions(List.of("session-1", "session-2"), "{\"type\":\"notice\",\"content\":\"指定连接\"}");
WebSockets.joinGroup("session-1", "room:general");
boolean inRoom = WebSockets.inGroup("session-1", "room:general");
Collection<String> groups = WebSockets.sessionGroups("session-1");
WebSockets.sendToGroup("room:general", "{\"type\":\"chat\",\"content\":\"大家好\"}");
WebSockets.sendToGroupExceptSession("room:general", "session-1", "{\"type\":\"chat\",\"content\":\"发给房间其他人\"}");
WebSockets.sendToGroupExceptUser("room:general", "10001", "{\"type\":\"chat\",\"content\":\"发给房间内其他用户\"}");
WebSockets.sendToGroups(List.of("room:general", "room:ops"), "{\"type\":\"platform\",\"content\":\"分组消息\"}");
WebSockets.leaveAllGroups("session-1");
WebSockets.closeUserDevice("10001", "app");
WebSockets.closeUserDevices("10001", List.of("web", "app"));
WebSockets.closeUsersByDevice(List.of("10001", "10002"), "web");
WebSockets.closeGroup("room:general");
WebSockets.broadcast("{\"type\":\"platform\",\"content\":\"平台维护提醒\"}");
WebSockets.broadcastExceptSession("session-1", "{\"type\":\"platform\",\"content\":\"发给除当前连接外所有人\"}");
WebSockets.broadcastExceptUser("10001", "{\"type\":\"platform\",\"content\":\"发给除当前用户外所有人\"}");
boolean userOnline = WebSockets.userOnline("10001");
boolean groupOnline = WebSockets.groupOnline("room:general");
Collection<String> onlineUserIds = WebSockets.onlineUserIds();
Collection<WebSocketSessionInfo> guests = WebSockets.guestSessions();
WebSockets.sendToGuests("{\"type\":\"notice\",\"content\":\"发给游客连接\"}");
WebSockets.closeGuests();
Collection<String> onlineGroups = WebSockets.onlineGroups();也可以注入 WebSocketSessionManager:
@RequiredArgsConstructor
@Service
public class DemoPushService {
private final WebSocketSessionManager sessionManager;
public void push(String userId, String content) {
sessionManager.sendToUser(userId, content);
}
}配置
spring:
open:
websocket:
enabled: true
endpoint: /api/ws
allowed-origins:
- "*"
allowed-origin-patterns: []
require-authentication: false
allow-query-token: true
user-id-parameter: userId
device-parameter: device
default-device: web
reconnect-interval: 0s
use-forwarded-headers: true
client-ip-headers:
- X-Forwarded-For
- X-Real-IP
send-time-limit: 10s
send-buffer-size-limit: 524288
text-message-size-limit: 65536
binary-message-size-limit: 65536
cluster:
enabled: false
node-id:
topic: websocket:commands常用环境变量:
SPRING_OPEN_WEBSOCKET_ENABLED=true
SPRING_OPEN_WEBSOCKET_ENDPOINT=/api/ws
SPRING_OPEN_WEBSOCKET_ALLOWED_ORIGIN=*
SPRING_OPEN_WEBSOCKET_REQUIRE_AUTHENTICATION=false
SPRING_OPEN_WEBSOCKET_ALLOW_QUERY_TOKEN=true
SPRING_OPEN_WEBSOCKET_USER_ID_PARAMETER=userId
SPRING_OPEN_WEBSOCKET_DEVICE_PARAMETER=device
SPRING_OPEN_WEBSOCKET_DEFAULT_DEVICE=web
SPRING_OPEN_WEBSOCKET_RECONNECT_INTERVAL=0s
SPRING_OPEN_WEBSOCKET_USE_FORWARDED_HEADERS=true
SPRING_OPEN_WEBSOCKET_CLUSTER_ENABLED=false
SPRING_OPEN_WEBSOCKET_CLUSTER_TOPIC=websocket:commands说明:
| 配置项 | 说明 |
|---|---|
enabled | 是否启用 WebSocket starter |
endpoint | 连接端点,默认 /api/ws |
allowed-origins | 允许的 Origin |
allowed-origin-patterns | 允许的 Origin pattern,非空时优先于 allowed-origins |
require-authentication | 是否要求连接必须解析出登录用户,默认 false |
allow-query-token | 是否允许从 URL 查询参数读取登录 Token,默认 true;生产可关闭并改用短期 WS ticket 或 Header 注入 |
user-id-parameter | 默认用户标识 URL 参数名 |
device-parameter | 设备标识 URL 参数名 |
default-device | 未传设备时的默认值 |
reconnect-interval | 同一来源、同一用户和同一设备的重连最小间隔,默认 0s 关闭;生产可设置 1s、3s 等防止断线风暴 |
use-forwarded-headers | 反向代理部署时是否优先读取转发头识别真实客户端 IP,默认 true |
client-ip-headers | 客户端 IP 转发头列表,默认 X-Forwarded-For、X-Real-IP;同一个用户 / 设备在不同真实客户端后面不会被 Nginx 地址合并限速 |
send-time-limit | 单次发送超时时间 |
send-buffer-size-limit | 单连接发送缓冲区大小 |
text-message-size-limit | 文本消息最大字节数 |
binary-message-size-limit | 二进制消息最大字节数 |
cluster.enabled | 是否启用 Redisson Topic 多实例分发 |
cluster.node-id | 当前节点标识,未配置时自动生成 |
cluster.topic | 多实例指令 Topic,会自动叠加 Redis key 前缀 |
机制说明
源码骨架:
| 组件 | 职责 |
|---|---|
WebSockets | 业务侧静态 Facade,由自动配置绑定当前 WebSocketSessionManager |
WebSocketSessionManager | 会话管理核心 contract,负责注册、注销、查询、点对点 / 用户 / 设备 / 分组 / 广播发送和关闭连接 |
SpringOpenWebSocketHandler | 默认文本消息入口,串联身份解析、强登录、重连限速、会话注册、消息分发、关闭清理和生命周期监听 |
WebSocketUserIdResolver | 握手用户标识解析扩展点;默认实现先读 Principal,再回退 URL userId |
WebSocketTextMessageHandler | 文本消息处理扩展点,按 Spring Bean 顺序同步执行 |
WebSocketSessionLifecycleListener | 连接、关闭和传输异常监听扩展点,监听器异常会被隔离 |
DefaultWebSocketSessionManager | 单实例内存会话管理器,维护 session / user / group 三类索引并清理已关闭连接 |
RedissonWebSocketSessionManager | 多实例装饰器,本机索引仍走内存 Manager,跨节点发送 / 关闭动作通过 Redisson Topic 广播 |
WebSocketProperties | 统一配置握手路径、Origin、强登录、URL Token、重连限速、消息大小和集群 Topic |
关键边界:
- WebSocket starter 只负责实时连接和底层推送,不负责业务会话内容、消息表、通知策略、后台权限或审计。
- 分组是运行时连接分组,不是业务聊天室模型;业务房间、成员权限和消息历史仍由 IAM / Conversation 等 owning core 模块承接。
- 默认
WebSocketTextMessageHandler同步执行,适合轻量命令、心跳、ACK 或事件入口;耗时处理应由业务处理器投递异步任务。 require-authentication=false时允许游客连接;请求携带无效凭证时,业务自定义WebSocketUserIdResolver可以抛出认证异常,默认 Handler 会用1008 Policy Violation关闭连接。- 重连限速只保护本机握手入口,不替代前端指数退避;多实例部署时如需全局限速,应在网关、Rate Limit 或业务 ticket 层处理。
- Redisson 集群模式只广播“发送 / 关闭”命令,不共享在线会话全局索引;
onlineCount()等查询仍表示当前节点可见连接。
部署检查
后端 starter 会在真实 Web 容器中注册 /api/ws 握手端点,项目测试已覆盖直接连接 ws://127.0.0.1:<port>/api/ws?userId=42&device=test 可以成功建立 WebSocket 会话。
如果通过 Nginx、网关或宝塔面板部署,推荐显式配置 location = /api/ws,并放在普通 /api/ 反代之前。这个写法不依赖全局 http {} 的 map,适合宝塔等只能编辑站点 server {} 的场景:
location = /api/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api/ {
set $spring_open_connection "";
if ($http_upgrade != "") {
set $spring_open_connection "upgrade";
}
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $spring_open_connection;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}路径调整注意事项:
/api/ws是当前推荐入口。REST API 和 WebSocket 同属/api/命名空间,部署时一个location /api/就能覆盖普通接口和实时连接,外层 Nginx / 网关 / 安全策略也更容易统一管理。- 不再把根路径
/ws作为默认入口,避免和前端静态站点、SPA fallback、站点页面或其他根路径规则混在一起。旧客户端如果仍配置/ws,需要同步改为/api/ws,或由部署方临时保留兼容转发。 proxy_pass后面不要追加/api/或/api/ws,应使用proxy_pass http://后端地址;保留原始 URI;否则 Nginx 可能把/api/ws重写成错误路径,导致握手失败。/api/ws会命中所有/api/**级别的网关、WAF、限流、日志和鉴权规则。新增这类规则时必须确认它们支持 WebSocket Upgrade,不要按普通 REST 请求直接拦截、改写请求体或强制短超时。- WebSocket 是长连接,
proxy_read_timeout/proxy_send_timeout建议明显长于普通接口;普通 API 可以继续使用较短业务超时,但合并 location 时需要照顾/api/ws。
是否拆分 Nginx location:
- 默认显式拆出:统一对外路径仍是
/api/ws,但 Nginx 用精确匹配location = /api/ws接住 WebSocket 握手,普通 REST API 使用location /api/。 - 如果确实只能保留一个
/api/location,也必须保留Upgrade/Connection头,并确认服务器面板或 WAF 没有把/api/ws当普通 REST 请求拦截。
可选拆分样板:
location = /api/ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}验收标准:
- 浏览器或 WebSocket 客户端连接
/api/ws?...返回101 Switching Protocols。 - 登录用户连接时优先携带
token,游客或内部调试才使用userId。 - 如果
/api/ws返回 JSON 404,先确认请求是否为真正 WebSocket 握手,再检查 Nginx/api/location 是否保留了 Upgrade 头、是否被静态兜底吞掉。
后台运维 API
spring-open-core-websocket 引入 WebSocket starter 后,会提供后台运维接口,方便 Admin 端查看运行配置快照、在线连接、在线分组、发送运维消息、关闭异常连接和调整会话分组。
配置开关:
spring:
open:
websocket:
enabled: true接口:
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
GET | /api/v1/admin/websocket/operations | 运行配置、连接概览、集群状态、能力清单和 runbook 提示 | websocket:session:view |
GET | /api/v1/admin/websocket/summary | 在线连接、用户、游客和分组概览 | websocket:session:view |
GET | /api/v1/admin/websocket/sessions | 分页查询在线会话,支持 keyword、userId、device、identityType、group 过滤 | websocket:session:view |
GET | /api/v1/admin/websocket/groups | 查询当前在线分组 | websocket:session:view |
POST | /api/v1/admin/websocket/messages | 发送运维消息 | websocket:message:send |
POST | /api/v1/admin/websocket/close | 关闭在线会话 | websocket:session:close |
POST | /api/v1/admin/websocket/sessions/{sessionId}/groups | 将连接加入分组 | websocket:group:update |
DELETE | /api/v1/admin/websocket/sessions/{sessionId}/groups/{group} | 将连接移出分组 | websocket:group:update |
发送消息示例:
{
"targetType": "group",
"target": "room:general",
"payload": "{\"type\":\"platform.notice\",\"content\":\"平台将在 10 分钟后维护\"}"
}关闭会话示例:
{
"targetType": "user-device",
"target": "10001",
"device": "web"
}目标类型:
| targetType | 发送 | 关闭 | target 含义 |
|---|---|---|---|
session | 支持 | 支持 | WebSocket sessionId |
user | 支持 | 支持 | 用户 ID |
user-device | 支持 | 支持 | 用户 ID,需同时传 device |
group | 支持 | 支持 | 分组名 |
guests | 支持 | 支持 | 不需要 target |
broadcast | 支持 | 不支持 | 不需要 target |
运维 API 只做运行配置观测和在线连接操作,不保存业务消息历史。需要离线可读、阅读回执、附件、失败重试或后台发送日志时,继续使用 Notification、Queue 和业务表。
用户侧会话 API
spring-open-core-conversation 提供用户侧会话 API,用于 App / PC 端实现私聊、群聊、会话列表、成员列表、未读数、已读和消息撤回。
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /api/v1/conversations | 分页查询我的会话,支持类型、关键字和未读过滤 |
GET | /api/v1/conversations/summary | 查询我的会话摘要,包含私聊 / 群聊 / 聊天室 / 客服、未读、置顶和免打扰计数 |
GET | /api/v1/conversations/unread-summary | 查询我的会话未读摘要,适合多端角标、会话列表和消息中心复用 |
GET | /api/v1/conversations/unread-count | 查询我的会话未读消息总数 |
POST | /api/v1/conversations/direct | 创建或查询私聊会话 |
POST | /api/v1/conversations/groups | 创建群聊会话 |
GET | /api/v1/conversations/rooms | 查询公开聊天室 |
POST | /api/v1/conversations/rooms | 创建或幂等获取公开聊天室;可传 roomKey 固定公共入口 |
GET | /api/v1/conversations/{id} | 查询我的会话详情 |
GET | /api/v1/conversations/{id}/messages | 分页查询会话消息,返回 sender 用户快照 |
POST | /api/v1/conversations/{id}/messages | 发送会话消息 |
GET | /api/v1/conversations/{id}/members | 查询会话成员,包含 user、online 和 onlineSessionCount |
POST | /api/v1/conversations/{id}/members | 群主添加群聊成员;已离开的成员会被重新加入 |
DELETE | /api/v1/conversations/{id}/members/{userId} | 群主移除群聊成员 |
PUT | /api/v1/conversations/{id}/settings | 更新当前用户的置顶、免打扰和会话显示名 |
PUT | /api/v1/conversations/{id}/profile | 群主或房主更新群聊 / 公开聊天室标题和头像 |
PUT | /api/v1/conversations/{id}/messages/{messageId}/withdraw | 撤回自己发送的消息 |
DELETE | /api/v1/conversations/{id}/messages/{messageId} | 仅从当前用户自己的消息列表删除消息 |
DELETE | /api/v1/conversations/{id}/messages | 请求体 messageIds 批量删除当前用户自己的消息 |
POST | /api/v1/conversations/{id}/typing | 推送当前用户正在输入或停止输入状态 |
POST | /api/v1/conversations/{id}/messages/ack-delivered | 确认消息送达,支持 messageId、messageIds 和 lastDeliveredMessageId |
POST | /api/v1/conversations/{id}/messages/ack-read | 确认消息已读,lastReadMessageId 只允许向前推进 |
DELETE | /api/v1/conversations/{id} | 离开会话 |
群聊成员管理第一阶段只允许群主操作,不能移除群主。群聊和公开聊天室资料也只允许群主 / 房主更新;私聊资料由用户资料卡和当前用户会话备注决定,不单独修改会话资料。会话被后台归档后,用户仍可读取历史消息,但不能继续发送新消息。消息撤回会影响所有成员,消息删除只隐藏当前用户自己的消息列表,并会重算当前用户未读数。会话设置中的 muted 是当前用户自己的免打扰开关:关闭提醒不会影响消息落库、未读数和 WebSocket 实时更新,只会阻止额外生成当前用户的聊天站内信。
产品视图发起私聊优先走 POST /api/v1/iam/contacts/{id}/conversation。这个入口把资料卡 / 好友列表和 Conversation Service 串起来,返回同一套会话 VO,避免普通用户界面出现“输入用户 ID 创建私聊”这种技术流程。
公开聊天室的 roomKey 会被服务端规范化为 room:{key} 并写入 conversationKey。相同 roomKey 重复提交会返回同一个房间,适合客服、公共大厅、订单咨询等固定入口;前端拿到房间后继续调用 POST /api/v1/conversations/{id}/join 加入即可。
会话列表和详情返回 conversationType、displayConversationType、title、displayTitle、memberCount、onlineMemberCount 和 hasOnlineMembers。前端展示优先使用 displayConversationType 和 displayTitle:会话类型由后端按当前语言解析,例如“私聊 / 群聊 / 聊天室”;标题则按用户自定义会话备注优先,私聊显示对方昵称,公开聊天室 / 群聊可通过 messages.conversation.<type>.<key>.title 做多语言展示,例如 room:general 对应 messages.conversation.room.general.title。成员摘要适合公开房间卡片、群聊设置页和后台只读观测;在线人数来自当前 WebSocket 在线快照,不作为离线历史事实保存。
会话运维 API
spring-open-core-conversation 还提供数据库会话运维入口,用于 Admin 端查看私聊 / 群聊会话、成员和历史消息。它和上面的 WebSocket 在线连接运维不同:WebSocket 管当前在线连接,会话运维管已经落库的业务聊天记录。
接口:
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
GET | /api/v1/admin/conversations | 分页查询会话,支持类型和关键字过滤 | conversation:view |
GET | /api/v1/admin/conversations/{id} | 查询会话详情 | conversation:view |
GET | /api/v1/admin/conversations/{id}/members | 查询会话成员,返回成员用户快照 | conversation:view |
GET | /api/v1/admin/conversations/{id}/messages | 分页查询会话消息,返回发送者用户快照 | conversation:view |
GET | /api/v1/admin/conversations/{id}/messages/{messageId}/receipts | 查询单条消息成员送达 / 已读回执,返回回执用户快照 | conversation:view |
PUT | /api/v1/admin/conversations/{id}/archive | 归档会话,阻止继续发送新消息 | conversation:update |
PUT | /api/v1/admin/conversations/{id}/restore | 恢复已归档会话 | conversation:update |
客服 / 意见反馈属于 spring-open-module-chat 业务模块,继续复用 Conversation 的消息、ACK 和实时事件底座:用户侧入口为 GET/POST /api/v1/chat/support 和 /api/v1/chat/support/messages,客服工作台入口为 GET /api/v1/admin/chat/support/queue、POST /api/v1/admin/chat/support/{id}/messages、PUT /api/v1/admin/chat/support/{id}/assignee。指派客服仍会推送 conversation.support.assigned,其余消息、输入中、已读和回执事件仍使用下方 conversation.* 契约。
聊天实时推送使用轻量事件 payload。前端收到事件后可以先更新当前页面状态,再按需调用分页接口补齐历史或做一致性刷新。
常见事件类型:
后端事件名集中维护在 ConversationEventTypes,三端对接时应以文档表格为产品契约,不要在页面里拼接事件名。
| type | 触发场景 |
|---|---|
conversation.message | 新消息发送成功 |
conversation.message.delivered | 成员确认消息已送达 |
conversation.message.read | 成员确认消息已读到指定消息 |
conversation.receipt.updated | 消息回执发生变化,统一通知三端刷新局部状态 |
conversation.message.withdrawn | 发送者撤回消息 |
conversation.message.deleted | 当前用户删除单条或多条本地消息,用于多端同步 |
conversation.settings.updated | 当前用户更新置顶、免打扰或会话显示名 |
conversation.profile.updated | 群主或房主更新群聊 / 公开聊天室标题和头像 |
conversation.members.added | 群主添加或重新加入成员 |
conversation.member.removed | 群主移除成员 |
conversation.member.left | 成员主动离开会话 |
conversation.status.updated | 后台归档或恢复会话 |
conversation.typing | 成员正在输入或停止输入 |
新消息事件会携带完整轻量消息摘要:
| 字段 | 说明 |
|---|---|
type | 事件类型,固定为 conversation.message |
conversationId | 会话 ID |
messageId | 消息 ID |
senderUserId | 发送者用户 ID |
clientMessageId | 客户端消息 ID,用于弱网重试幂等 |
content | 消息正文 |
contentType | 消息类型 |
attributes | 扩展属性 |
replyToMessageId | 回复的消息 ID |
status | 消息状态 |
sentAt | 发送时间 |
状态类事件至少包含 type、conversationId、会话快照 conversation 和动作相关字段,例如 userId、userIds、status、messageIds、lastDeliveredMessageId、lastReadMessageId、deliveredAt、readAt、pinned、muted、displayName、title、avatarUrl、typing。成员、设置、资料、状态、输入中和删除等事件都会尽量返回前端可直接渲染的 user / member / members / operator 快照,避免三端收到事件后立刻回查基础资料。其中 conversation.settings.updated 和 conversation.message.deleted 只推送给当前用户,conversation.typing 只推送给其他在线成员,其他会话状态事件会推送给相关会话成员;被移除或主动离开的成员也会收到对应事件,方便前端及时退出会话详情页。
客户端入站 ACK 使用 conversation.ack-delivered 和 conversation.ack-read,WebSocket 只是实时入口,REST ACK 接口仍是可靠兜底:
{
"type": "conversation.ack-delivered",
"conversationId": 1001,
"messageIds": [2001, 2002],
"lastDeliveredMessageId": 2002,
"device": "app"
}{
"type": "conversation.ack-read",
"conversationId": 1001,
"lastReadMessageId": 2002,
"device": "app"
}服务端会在数据库事务提交后再推送 conversation.message、conversation.message.delivered、conversation.message.read、conversation.message.deleted 和 conversation.receipt.updated。新消息还会在事务提交后发布标准通知事件:未静音成员会按 chatEnabled 等通知偏好写入站内信,方便离线消息中心展示。前端收到 WebSocket 事件后可以局部更新 UI,但最终一致性以 REST 分页和 ACK / 删除结果为准。
成员在线状态通过 REST 成员列表返回:online 表示当前是否存在在线连接,onlineSessionCount 表示该用户当前在线连接数。该值来自 WebSocket 会话管理器快照,适合成员列表、聊天室在线标识和后台会话运维展示;在线状态不落库,离线历史不作为业务事实保存。
核心对象
| 类型 | 说明 |
|---|---|
WebSockets | Facade,适合业务代码快速发送和查询在线会话 |
WebSocketSessionManager | 会话管理接口,负责注册、注销、查询、分组、发送和关闭连接 |
WebSocketSessionInfo | 会话快照,包含 sessionId、userId、device 等信息 |
WebSocketUserIdResolver | 用户标识解析扩展点 |
WebSocketTextMessageHandler | 文本消息处理扩展点 |
WebSocketMessageContext | 文本消息上下文,包含原始 session、会话快照、payload 和 manager |
WebSocketSessionLifecycleListener | 会话生命周期监听扩展点,适合连接审计、在线状态广播和进入/退出提示 |
WebSocketSessionEventContext | 生命周期事件上下文,包含 session、会话快照、关闭状态、异常和 manager |
常用方法:
| 方法 | 说明 |
|---|---|
sessions() | 查询当前实例在线会话 |
session(sessionId) | 查询指定会话 |
onlineUserIds() | 查询当前实例在线用户 ID 列表 |
guestSessions() | 查询当前实例游客连接 |
guestOnlineCount() | 当前实例游客连接数 |
guestOnline() | 判断当前实例是否存在游客连接 |
userSessions(userId) | 查询指定用户在线会话 |
userDeviceSessions(userId, device) | 查询指定用户某个设备的在线会话 |
groupSessions(group) | 查询指定分组当前实例在线会话 |
sessionGroups(sessionId) | 查询指定连接当前加入的分组 |
onlineGroups() | 查询当前实例存在在线连接的分组列表 |
onlineCount() | 当前实例在线连接数 |
sessionOnline(sessionId) | 判断指定连接是否在线 |
userOnlineCount(userId) | 指定用户在线连接数 |
userOnline(userId) | 判断指定用户是否在线 |
userDeviceOnlineCount(userId, device) | 指定用户某个设备在线连接数 |
userDeviceOnline(userId, device) | 判断指定用户某个设备是否在线 |
groupOnlineCount(group) | 指定分组当前实例在线连接数 |
groupOnline(group) | 判断指定分组是否有在线连接 |
inGroup(sessionId, group) | 判断指定连接是否已加入分组 |
joinGroup(sessionId, group) | 将指定连接加入分组 |
leaveGroup(sessionId, group) | 将指定连接移出分组 |
leaveAllGroups(sessionId) | 将指定连接移出全部分组 |
sendToSession(sessionId, payload) | 发送给指定连接 |
sendToSessions(sessionIds, payload) | 发送给多个指定连接 |
sendToUser(userId, payload) | 发送给指定用户的所有连接 |
sendToUserExceptSession(userId, sessionId, payload) | 发送给指定用户除某个连接外的其他连接 |
sendToUserDevice(userId, device, payload) | 发送给指定用户的指定设备连接 |
sendToUserDevices(userId, devices, payload) | 发送给指定用户的多个指定设备连接 |
sendToUsers(userIds, payload) | 发送给多个用户的所有连接 |
sendToUsersByDevice(userIds, device, payload) | 发送给多个用户的指定设备连接 |
sendToGuests(payload) | 发送给当前实例所有游客连接 |
sendToGroup(group, payload) | 发送给指定分组的所有连接 |
sendToGroupExceptSession(group, sessionId, payload) | 发送给指定分组内除某个连接外的其他连接 |
sendToGroupExceptUser(group, userId, payload) | 发送给指定分组内除某个用户所有连接外的其他连接 |
sendToGroups(groups, payload) | 发送给多个分组的所有连接 |
broadcast(payload) | 广播给所有连接 |
broadcastExceptSession(sessionId, payload) | 广播给除某个连接外的其他连接 |
broadcastExceptUser(userId, payload) | 广播给除某个用户所有连接外的其他连接 |
closeSession(sessionId) | 关闭指定连接 |
closeSessions(sessionIds) | 关闭多个指定连接 |
closeUser(userId) | 关闭指定用户所有连接 |
closeUserDevice(userId, device) | 关闭指定用户的指定设备连接 |
closeUserDevices(userId, devices) | 关闭指定用户的多个指定设备连接 |
closeUsers(userIds) | 关闭多个用户的所有连接 |
closeUsersByDevice(userIds, device) | 关闭多个用户的指定设备连接 |
closeGuests() | 关闭当前实例所有游客连接 |
closeGroup(group) | 关闭指定分组内的所有连接 |
closeGroups(groups) | 关闭多个分组内的所有连接 |
分组发送
分组是运行期会话索引,适合聊天室房间、项目频道、任务频道、订单频道等场景。分组不落库,也不定义业务协议;业务模块应根据自己的权限和场景决定什么时候加入或离开分组。
内置内存 Manager 和 Redisson Manager 都支持分组能力。自定义 WebSocketSessionManager 如果暂时不实现分组,接口默认会返回空集合、false 或 0,不会因为业务误调用分组方法而直接抛出异常;需要分组能力时再覆盖对应方法即可。
WebSockets.joinGroup(sessionId, "room:general");
WebSockets.inGroup(sessionId, "room:general");
WebSockets.sessionGroups(sessionId);
WebSockets.sendToGroup("room:general", json);
WebSockets.sendToGroupExceptSession("room:general", sessionId, json);
WebSockets.sendToGroupExceptUser("room:general", userId, json);
WebSockets.leaveGroup(sessionId, "room:general");
WebSockets.leaveAllGroups(sessionId);
WebSockets.closeGroup("room:general");建议:
- 分组名使用清晰前缀,例如
room:{id}、project:{id}、task:{id}。 - 加入分组前先完成业务权限校验。
- 连接断开后,starter 会自动清理该连接的分组索引。
- 业务切换房间、退出项目或重新绑定身份时,可以先调用
leaveAllGroups清空旧分组,再按业务权限重新加入。 - 多实例启用后,
sendToGroup会通过 Redisson Topic 广播给其他实例,由各实例只发送给本机属于该分组的连接。 - 聊天室、协作编辑和在线状态同步通常使用
sendToGroupExceptSession,避免把用户刚发送的消息再原样推回当前连接。 - 如果同一用户可能有多个设备在线,优先使用
sendToGroupExceptUser,避免当前用户其他设备收到自己刚触发的房间消息。 closeGroup适合房间关闭、任务频道结束或管理员清理异常连接;它只关闭在线连接,不删除业务房间、聊天记录或通知数据。
用户绑定
默认 starter 的 WebSocketUserIdResolver 解析顺序:
- 优先读取
WebSocketSession.getPrincipal().getName()。 - 没有 Principal 时,读取连接 URL 中的
userId参数。
接入 spring-open-core-websocket 和 spring-open-core-iam 后,Core WebSocket 会注册登录态解析器,并覆盖为更适合生产的顺序:
- 优先读取
WebSocketSession.getPrincipal().getName()。 - 读取
token、accessToken、access_token或Authorization=Bearer <token>查询参数,也支持Authorization: Bearer <token>请求头,通过AuthManager.getTokenInfo(token)解析登录用户。 - 没有 token 时才回退到连接 URL 中的
userId参数,适合游客、样板或内部调试场景。
开发环境可以直接使用:
WS /api/ws?userId=demo&device=browserdevice 用来区分同一用户的不同终端,例如 web、app、admin、openapi。业务可以按用户全端推送,也可以按指定设备推送:
WebSockets.sendToUser("10001", json); // 推送到该用户所有在线设备
WebSockets.sendToUserExceptSession("10001", sessionId, json); // 推送到该用户其他连接
WebSockets.sendToUserDevice("10001", "web", json); // 只推送到 Web 端
WebSockets.sendToUserDevices("10001", List.of("web", "app"), json); // 推送到多个指定端
WebSockets.sendToUsersByDevice(List.of("10001", "10002"), "web", json); // 推送多个用户的 Web 端
WebSockets.closeUserDevice("10001", "app"); // 只关闭 App 端连接
WebSockets.closeUsersByDevice(List.of("10001", "10002"), "web"); // 关闭多个用户的 Web 端连接生产环境不要信任前端直接传入的 userId。使用 IAM 模块时,推荐前端只传登录 token;业务自定义认证体系时,再通过 Spring 标准 HandshakeInterceptor 校验 token,并注册自己的 WebSocketUserIdResolver 从握手属性、Sa-Token、JWT 或其他身份来源解析用户。
如果客户端断线后出现无退避快速重连,可以设置 spring.open.websocket.reconnect-interval=1s 或 3s。该限速只针对同一来源、同一用户或游客、同一设备的短间隔重复握手,默认关闭,不影响正常多端在线;前端仍应实现指数退避和登录失效停止重连。
WebSocketSessionInfo 会根据 userId 自动区分用户和游客:
if (info.isGuest()) {
// 游客连接:未绑定后端用户,只能使用游客权限。
}
if (info.isUser()) {
// 用户连接:userId 已由 WebSocketUserIdResolver 解析。
}
String type = info.identityType(); // user / guest游客能力适合公开临时聊天室、公开咨询、临时访客通知等场景。正式业务仍应在发送消息、加入分组或执行私聊前完成业务权限校验;游客不能假装成用户,用户身份必须由服务端解析。
示例:
@Component
public class TokenWebSocketUserIdResolver implements WebSocketUserIdResolver {
@Override
public Optional<String> resolve(WebSocketSession session) {
Object loginId = session.getAttributes().get("loginId");
return Optional.ofNullable(loginId).map(String::valueOf);
}
}starter 会自动接入容器中已有的 HandshakeInterceptor,不在 starter 内硬编码 IAM 用户表或 Sa-Token 细节。
文本消息处理
注册 WebSocketTextMessageHandler 即可处理客户端发来的文本消息:
@Component
public class EchoMessageHandler implements WebSocketTextMessageHandler {
@Override
public void handle(WebSocketMessageContext context) {
context.manager().sendToSession(context.sessionInfo().sessionId(), context.payload());
}
}建议业务消息统一使用 JSON,并保留 type 字段:
{
"type": "task.progress",
"taskId": "T10001",
"percent": 80
}WebSocket starter 只负责连接、会话和分发,不定义业务消息协议。聊天室、通知、AI 任务等协议放在各自业务模块中;教学片段写入正式文档,不再恢复独立样例模块。
心跳保活
WebSocket starter 内置应用层心跳处理器 WebSocketHeartbeatMessageHandler,对所有连接(含游客)生效:客户端发送 {"type":"ping"},服务端回 {"type":"pong"},心跳消息不进入业务消息管线。
客户端应按固定间隔(建议 25s)发送 ping,作用有两点:
- 保活:浏览器 / 移动端无法主动发 WebSocket 协议层 ping 帧,应用层 ping 在客户端、Nginx / 负载均衡和后端之间持续产生双向流量,避免空闲连接被代理按读超时(如反向代理常见 60s)断开。
- 探测半开连接:客户端收到任意消息(含 pong)即刷新存活时间戳;超过存活阈值(建议 60s)无任何消息,判定为半开连接并强制重连。
// 客户端 → 服务端
{ "type": "ping" }
// 服务端 → 客户端
{ "type": "pong" }框架前端(App / PC / Admin)实时客户端已内置该心跳与半开探测,业务无需额外处理;自研客户端按上述协议实现即可。心跳不替代业务重连,断开后仍由各端重连策略恢复。
生命周期监听
注册 WebSocketSessionLifecycleListener 即可监听连接建立、连接关闭和传输异常:
@Component
public class OnlineStatusListener implements WebSocketSessionLifecycleListener {
@Override
public void afterConnected(WebSocketSessionEventContext context) {
WebSocketSessionInfo info = context.sessionInfo();
context.manager().sendToUser(info.userId(), "{\"type\":\"online\"}");
}
@Override
public void afterClosed(WebSocketSessionEventContext context) {
WebSocketSessionInfo info = context.sessionInfo();
// 记录离线审计、广播房间离开事件等
}
}注意:
- 生命周期监听器用于扩展业务行为,不应该承载重业务逻辑;耗时任务建议投递到 Queue。
- 监听器异常会被 starter 记录并吞掉,不会影响连接注册、注销和关闭流程。
afterClosed和afterTransportError会在会话注销后触发,但事件上下文中仍会保留注销前的会话快照。
多实例分发
WebSocket 连接只能存在于当前 JVM。多实例部署时,不要尝试把 WebSocketSession 放入 Redis。
当前多实例方案:
当前实例 WebSocketSessionManager
-> Redisson Topic 发布发送/关闭指令
-> 其他实例收到指令
-> 对本机连接执行 send / close启用配置:
spring:
open:
data:
redis:
enabled: true
websocket:
cluster:
enabled: true
topic: websocket:commands前提:
- 应用引入
spring-open-starter-data-redis。 - Redis 连接配置正确。
- 容器中存在
RedissonManager。
注意:
- Redisson Topic 适合实时在线推送,不是可靠消息队列。
- 离线消息、失败重试和消息持久化应放在 Notification、Queue 或业务数据库中。
sessions()和onlineCount()返回当前实例视角,不是全局在线总数。
本地多实例预览使用内置 websocket-cluster profile:
docker compose -f docker/docker-compose.yml --profile websocket-cluster up -d --build --remove-orphans
./scripts/docker smoke --websocket-cluster --base-url http://127.0.0.1:18080
./scripts/docker websocket-cluster-acceptance --no-build该 profile 会启动 app-ws-1 / app-ws-2 两个应用节点,并通过独立的 nginx-websocket-cluster 暴露入口。两个节点会显式启用 Redisson WebSocket cluster 和不同 node-id,验证重点是“REST 请求落在一个实例、WebSocket 连接落在另一个实例”时,私聊、群聊、公开聊天室和通知事件仍能实时送达。
websocket-cluster-acceptance 会自动启动 profile、等待健康检查、创建两个原生 WebSocket 客户端并通过 X-Upstream-Addr 响应头证明它们落到不同 upstream,然后分别验证用户目标通知 payload 与分组聊天 payload 跨实例到达。命令默认使用临时 MySQL / Redis 数据目录并在结束后清理,避免本地旧 docker/data/mysql 的 Liquibase checksum 状态污染验收;需要复用本地数据时传 --reuse-data。
Notification 集成
Notification starter 提供可选 websocket 通道:
spring:
open:
notification:
websocket:
enabled: true
broadcast-when-recipients-empty: false发送给在线用户:
Notification.send("websocket", NotificationMessage.builder()
.subject("任务完成")
.content("你的导出任务已经完成")
.recipient(NotificationRecipient.user(10001L))
.build());也可以按连接、分组或游客连接投递:
Notification.send("websocket", NotificationMessage.builder()
.content("房间公告")
.recipient(NotificationRecipient.websocketGroup("room:general"))
.build());
Notification.send("websocket", NotificationMessage.builder()
.content("公开访客提醒")
.recipient(NotificationRecipient.websocketGuest())
.build());边界:
websocket通道只负责在线实时投递。websocket接收人等同于按用户投递;指定连接使用websocket-session,指定分组使用websocket-group,游客连接使用websocket-guest。- 需要离线可读、阅读回执、附件和后台运维时,使用
database站内信通道。 - 需要邮件或短信提醒时,组合
mail、sms通道。
正式聊天能力
WebSocket starter 只提供连接和实时投递底座,不再内置聊天室样板。正式聊天体验由 Conversation 提供,包括会话列表、私聊、群聊、公开聊天室、未读、已读 ACK 和通知联动。
常用入口:
GET /api/v1/conversations
GET /api/v1/conversations/{id}/messages
POST /api/v1/conversations/{id}/messages
POST /api/v1/conversations/{id}/messages/ack-read
WS /api/ws?token={token}&device=browser没有 token 的游客连接仍可用于公开访客提醒、临时咨询或内部调试;正式业务发送消息、加入群组或访问附件前,应在业务 Service 层完成成员、好友、拉黑、私有附件等权限校验。
运维边界
WebSocket starter 不保存业务消息,也不提供数据库表。
推荐边界:
| 场景 | 建议模块 |
|---|---|
| 在线实时推送 | WebSocket |
| 离线可读站内信 | Notification database 通道 |
| 失败重试和异步任务 | Queue |
| 定时补偿和周期任务 | Scheduler |
| 跨实例实时发送指令 | WebSocket + Redisson Topic |
| 可靠事件通知 | Event / Queue / 业务表 |
验证命令
WebSocket starter:
./mvnw -pl spring-open-starters/spring-open-starter-websocket -am test -DskipITs正式聊天体验由 Conversation 装配,WebSocket starter 只验证连接、会话和分发底座。