跳到内容

WebSocket

受众:开发者 摘要:WebSocket starter:连接管理 / 用户绑定 / 设备推送 / 分组发送 / 广播 / Redisson 多实例。

WebSocket 提供实时连接、在线会话管理、点对点发送、按用户发送、按分组发送、广播和多实例分发能力。

它适合聊天室、站内实时通知、AI 任务状态推送、支付/Web3 状态提醒等场景。普通后台通知请优先看 Notification,Redis 基础能力请看 Redis

快速使用

引入 starter:

xml
<dependency>
    <groupId>com.springopen</groupId>
    <artifactId>spring-open-starter-websocket</artifactId>
</dependency>

默认连接端点:

text
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,使用返回的 websocketUrlwebsocketEndpointwebsocketTokenParameterwebsocketDeviceParameter 建立连接。websocketUrl 会根据站点 URL 自动推导 ws://wss://;为空时,前端用当前 location.originwebsocketEndpoint 拼接即可。这样 Docker / Nginx / HTTPS / 子路径部署时,实时连接地址仍由后端 contract 统一兜底。

接入 spring-open-core-websocketspring-open-core-iam 后,WebSocket 会优先根据登录 Token 解析用户身份。浏览器端推荐使用 token 查询参数,服务端或原生客户端也可以使用 Authorization: Bearer <token>;如果 Header 和查询参数同时存在,Header 优先。没有 Token 时才回退到 userId 参数,适合游客、样板或内部调试场景。

如果请求携带了 Token 但 Token 无效,连接会被服务端关闭为 1008 Policy Violation,不会静默降级成游客连接。这样可以避免“握手成功但用户在线会话没有绑定,定向推送找不到接收方”的问题。

text
WS /api/ws?token=<access-token>&device=browser

生产环境如果不希望 Token 出现在 Nginx access log 或浏览器地址中,可以关闭查询参数 Token:

yaml
spring:
  open:
    websocket:
      allow-query-token: false

关闭后,后端只从 Authorization: Bearer <token> 这类握手 Header 读取 Token。浏览器 WebSocket 原生 API 不能自定义 Header,因此 Web 端生产环境更推荐再做一层短期 WebSocket ticket,或由网关 / BFF 把鉴权结果注入握手上下文。

如果某个部署场景不允许游客连接,可以开启强登录:

yaml
spring:
  open:
    websocket:
      require-authentication: true

开启后,未解析出登录用户的连接同样会被关闭为 1008 Policy Violation。默认值为 false,保留公开聊天室、演示页和游客聊天的使用空间。

普通 GET /api/ws 不是 WebSocket 握手请求,缺少 Upgrade: websocket 头时返回 404 或普通 HTTP 响应都不能说明 WebSocket 不可用。联调时请使用浏览器 WebSocket、wscatwebsocat 或前端真实连接检查 101 Switching Protocols

发送消息:

java
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

java
@RequiredArgsConstructor
@Service
public class DemoPushService {

    private final WebSocketSessionManager sessionManager;

    public void push(String userId, String content) {
        sessionManager.sendToUser(userId, content);
    }
}

配置

yaml
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

常用环境变量:

dotenv
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 关闭;生产可设置 1s3s 等防止断线风暴
use-forwarded-headers反向代理部署时是否优先读取转发头识别真实客户端 IP,默认 true
client-ip-headers客户端 IP 转发头列表,默认 X-Forwarded-ForX-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 {} 的场景:

nginx
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 请求拦截。

可选拆分样板:

nginx
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 端查看运行配置快照、在线连接、在线分组、发送运维消息、关闭异常连接和调整会话分组。

配置开关:

yaml
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分页查询在线会话,支持 keyworduserIddeviceidentityTypegroup 过滤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

发送消息示例:

json
{
  "targetType": "group",
  "target": "room:general",
  "payload": "{\"type\":\"platform.notice\",\"content\":\"平台将在 10 分钟后维护\"}"
}

关闭会话示例:

json
{
  "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查询会话成员,包含 useronlineonlineSessionCount
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确认消息送达,支持 messageIdmessageIdslastDeliveredMessageId
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 加入即可。

会话列表和详情返回 conversationTypedisplayConversationTypetitledisplayTitlememberCountonlineMemberCounthasOnlineMembers。前端展示优先使用 displayConversationTypedisplayTitle:会话类型由后端按当前语言解析,例如“私聊 / 群聊 / 聊天室”;标题则按用户自定义会话备注优先,私聊显示对方昵称,公开聊天室 / 群聊可通过 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/queuePOST /api/v1/admin/chat/support/{id}/messagesPUT /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发送时间

状态类事件至少包含 typeconversationId、会话快照 conversation 和动作相关字段,例如 userIduserIdsstatusmessageIdslastDeliveredMessageIdlastReadMessageIddeliveredAtreadAtpinnedmuteddisplayNametitleavatarUrltyping。成员、设置、资料、状态、输入中和删除等事件都会尽量返回前端可直接渲染的 user / member / members / operator 快照,避免三端收到事件后立刻回查基础资料。其中 conversation.settings.updatedconversation.message.deleted 只推送给当前用户,conversation.typing 只推送给其他在线成员,其他会话状态事件会推送给相关会话成员;被移除或主动离开的成员也会收到对应事件,方便前端及时退出会话详情页。

客户端入站 ACK 使用 conversation.ack-deliveredconversation.ack-read,WebSocket 只是实时入口,REST ACK 接口仍是可靠兜底:

json
{
  "type": "conversation.ack-delivered",
  "conversationId": 1001,
  "messageIds": [2001, 2002],
  "lastDeliveredMessageId": 2002,
  "device": "app"
}
json
{
  "type": "conversation.ack-read",
  "conversationId": 1001,
  "lastReadMessageId": 2002,
  "device": "app"
}

服务端会在数据库事务提交后再推送 conversation.messageconversation.message.deliveredconversation.message.readconversation.message.deletedconversation.receipt.updated。新消息还会在事务提交后发布标准通知事件:未静音成员会按 chatEnabled 等通知偏好写入站内信,方便离线消息中心展示。前端收到 WebSocket 事件后可以局部更新 UI,但最终一致性以 REST 分页和 ACK / 删除结果为准。

成员在线状态通过 REST 成员列表返回:online 表示当前是否存在在线连接,onlineSessionCount 表示该用户当前在线连接数。该值来自 WebSocket 会话管理器快照,适合成员列表、聊天室在线标识和后台会话运维展示;在线状态不落库,离线历史不作为业务事实保存。

核心对象

类型说明
WebSocketsFacade,适合业务代码快速发送和查询在线会话
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 如果暂时不实现分组,接口默认会返回空集合、false0,不会因为业务误调用分组方法而直接抛出异常;需要分组能力时再覆盖对应方法即可。

java
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 解析顺序:

  1. 优先读取 WebSocketSession.getPrincipal().getName()
  2. 没有 Principal 时,读取连接 URL 中的 userId 参数。

接入 spring-open-core-websocketspring-open-core-iam 后,Core WebSocket 会注册登录态解析器,并覆盖为更适合生产的顺序:

  1. 优先读取 WebSocketSession.getPrincipal().getName()
  2. 读取 tokenaccessTokenaccess_tokenAuthorization=Bearer <token> 查询参数,也支持 Authorization: Bearer <token> 请求头,通过 AuthManager.getTokenInfo(token) 解析登录用户。
  3. 没有 token 时才回退到连接 URL 中的 userId 参数,适合游客、样板或内部调试场景。

开发环境可以直接使用:

text
WS /api/ws?userId=demo&device=browser

device 用来区分同一用户的不同终端,例如 webappadminopenapi。业务可以按用户全端推送,也可以按指定设备推送:

java
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=1s3s。该限速只针对同一来源、同一用户或游客、同一设备的短间隔重复握手,默认关闭,不影响正常多端在线;前端仍应实现指数退避和登录失效停止重连。

WebSocketSessionInfo 会根据 userId 自动区分用户和游客:

java
if (info.isGuest()) {
    // 游客连接:未绑定后端用户,只能使用游客权限。
}

if (info.isUser()) {
    // 用户连接:userId 已由 WebSocketUserIdResolver 解析。
}

String type = info.identityType(); // user / guest

游客能力适合公开临时聊天室、公开咨询、临时访客通知等场景。正式业务仍应在发送消息、加入分组或执行私聊前完成业务权限校验;游客不能假装成用户,用户身份必须由服务端解析。

示例:

java
@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 即可处理客户端发来的文本消息:

java
@Component
public class EchoMessageHandler implements WebSocketTextMessageHandler {

    @Override
    public void handle(WebSocketMessageContext context) {
        context.manager().sendToSession(context.sessionInfo().sessionId(), context.payload());
    }
}

建议业务消息统一使用 JSON,并保留 type 字段:

json
{
  "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)无任何消息,判定为半开连接并强制重连。
json
// 客户端 → 服务端
{ "type": "ping" }
// 服务端 → 客户端
{ "type": "pong" }

框架前端(App / PC / Admin)实时客户端已内置该心跳与半开探测,业务无需额外处理;自研客户端按上述协议实现即可。心跳不替代业务重连,断开后仍由各端重连策略恢复。

生命周期监听

注册 WebSocketSessionLifecycleListener 即可监听连接建立、连接关闭和传输异常:

java
@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 记录并吞掉,不会影响连接注册、注销和关闭流程。
  • afterClosedafterTransportError 会在会话注销后触发,但事件上下文中仍会保留注销前的会话快照。

多实例分发

WebSocket 连接只能存在于当前 JVM。多实例部署时,不要尝试把 WebSocketSession 放入 Redis。

当前多实例方案:

text
当前实例 WebSocketSessionManager
  -> Redisson Topic 发布发送/关闭指令
  -> 其他实例收到指令
  -> 对本机连接执行 send / close

启用配置:

yaml
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:

bash
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 通道:

yaml
spring:
  open:
    notification:
      websocket:
        enabled: true
        broadcast-when-recipients-empty: false

发送给在线用户:

java
Notification.send("websocket", NotificationMessage.builder()
        .subject("任务完成")
        .content("你的导出任务已经完成")
        .recipient(NotificationRecipient.user(10001L))
        .build());

也可以按连接、分组或游客连接投递:

java
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 站内信通道。
  • 需要邮件或短信提醒时,组合 mailsms 通道。

正式聊天能力

WebSocket starter 只提供连接和实时投递底座,不再内置聊天室样板。正式聊天体验由 Conversation 提供,包括会话列表、私聊、群聊、公开聊天室、未读、已读 ACK 和通知联动。

常用入口:

text
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:

bash
./mvnw -pl spring-open-starters/spring-open-starter-websocket -am test -DskipITs

正式聊天体验由 Conversation 装配,WebSocket starter 只验证连接、会话和分发底座。

Released under the Apache License 2.0.