添加websocket推送框架。

This commit is contained in:
zfjia committed 2026-11-12 14:00:31 +08:00
1 parent bacdd15f3a
commit 83ece20a00
22 files changed
+1462 -7

No files matched your search

@@ -0,0 +1,258 @@
# 21 WebSocket 实时推送设计(值班员通道)
> 适用版本:apt-rev01(IT-6 增量)
> 代码落点:`ruoyi-apt/.../framework/realtime/**`、`framework/web/AptContextInterceptor`、`ruoyi-framework/config/SecurityConfig`
> 库变更:`dev/sql/apt_rev01/30_realtime.sql`(已执行:权限点 `apt:realtime:connect`)
> 目标:叫乘状态变更、报警事件等值班业务在 1s 内到达值班台;断线/重连不丢业务事实(以 HTTP 快照补齐)
## 1. 设计边界
| 项 | 结论 |
| --- | --- |
| 通道形态 | 原生 WebSocket(`TextWebSocketHandler`),**不引入 STOMP/SockJS**,避免与若依前端动态路由/网关冲突 |
| 进程模型 | 一公寓一进程(D5),会话注册表放进程内 `ConcurrentHashMap`;多实例需换 Redis Pub/Sub |
| 终端 | 值班员浏览器(`/apt/**` 登录态);平板/设备仍走 HTTP,不接 WS |
| 可靠性 | WS 只做"低延迟通知",**不作为业务事实来源**;每条消息可由 HTTP 查询复核 |
| 降级 | `apt.realtime.enabled=false` 时不注册 `/apt/ws`,前端自动退回轮询,既有接口零改动 |
| 兼容 | 不改任何既有 HTTP 接口出参与状态机;推送失败仅告警,不影响事务 |
## 2. 连接建立与鉴权
### 2.1 连接地址
```
wss://<host>/apt/ws?token=<JWT>
```
- 浏览器 `WebSocket` 无法自定义 `Authorization` 头,因此令牌走 query;非浏览器客户端仍支持 `Authorization: Bearer <JWT>`。
- 生产必须使用 `wss`,并由反向代理限制 Origin。
### 2.2 握手流程
```
WebSocket 握手 GET /apt/ws?token=...
→ Spring Security:/apt/ws 放行(不做表单登录)
→ AptContextInterceptor:仅注入 gyid,跳过 HTTP 会话断言
→ AptRealtimeHandshakeInterceptor:
1) TokenService 校验 JWT + Redis 会话(复用若依登录态,30 分钟续期规则不变)
2) 校验权限点 apt:realtime:connect(超管 *:*:* 直接放行)
3) 将 userId / username / gyid / permissions 写入握手 attributes
→ 建立连接,服务端下发 CONNECTED(含 heartbeatIntervalSec / serverTime)
```
关键约束:
| 约束 | 说明 |
| --- | --- |
| gyid 来源 | 只取部署配置 `apt.gy-id`,**绝不采信 query/header**(D5) |
| 权限快照 | 权限在连接时快照;角色变更需断开重连才生效(避免每帧查库) |
| 拒绝响应 | 无令牌 401;令牌失效 401;缺权限点 403;前端据此停止重试并跳登录 |
| 会话数量 | 同一账号允许多标签页/多终端,注册表按 sessionId 独立记录 |
## 3. 心跳保活与断线重连
| 项 | 策略 |
| --- | --- |
| 心跳方向 | 客户端主动 PING(服务端不主动探测,避免空转) |
| 间隔 | 服务端在 `CONNECTED.data.heartbeatIntervalSec` 下发,默认 25s |
| 报文 | `{"type":"PING"}` → `{"type":"PONG","data":{"serverTime":...}}` |
| 超时 | 连续 3 次(约 75s)未收到 PONG,客户端主动 `close()` 并重连 |
| 重连退避 | 1s → 2s → 4s → 8s → 16s → 30s 封顶,±20% 抖动;握手 401/403 不重试 |
| 重连后补齐 | 重连成功先拉一次 HTTP 快照(叫乘列表/统计、工单列表),WS 只补增量,不要求服务端补发历史 |
| 半开连接 | `AptRealtimeSessionRegistry.sweepIdle(idleMs)` 已提供,可挂 Quartz 每 30s 清理(本期未挂,见 §10) |
| 代理要求 | Nginx `proxy_read_timeout ≥ 120s`;`Upgrade`/`Connection` 头透传 |
## 4. 消息格式与分类标准
### 4.1 信封字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | string | 消息唯一 ID(幂等/去重,前端可按 id 丢弃重复) |
| `type` | string | 业务消息类型,见 §4.2 |
| `category` | string | 分类:`CALL/ALARM/CHECKIN/FACE/JOB/DEVICE/REPORT/SYSTEM` |
| `priority` | string | `P0/P1/P2`,决定前端声音/角标/是否置顶 |
| `gyid` | string | 公寓标识,服务端填充 |
| `occurredAt` | long | 事件时间(毫秒);前端按此排序,不按到达时间 |
| `traceId` | string | 与 HTTP 响应/审计日志关联 |
| `targetScope` | string | `PERMISSION/USER/ALL` |
| `targetPermission` | string | 权限点(PERMISSION 时有效) |
| `targetUserId` | long | 指定值班员(USER 时有效) |
| `data` | object | 业务载荷,按 type 约定 |
### 4.2 分类与优先级
| category | 优先级 | 默认投递权限 | 场景 |
| --- | --- | --- | --- |
| `ALARM` | P0 | `apt:jcxx:list` | 叫乘报警、未刷脸需确认、离寓超时报警 |
| `CALL` | P1 | `apt:jcxx:list` | 叫乘状态迁移(开始/应答/停止/复叫/强结/离寓/取消) |
| `CHECKIN` | P1 | `duty:checkin:list` | 入住申请待审、审核结果 |
| `FACE` | P1 | `duty:person:reg` | 人脸注册申请待确认 |
| `JOB` | P1 | `apt:workorder:list` | 保洁自动建单、报修建单、派单、完成 |
| `DEVICE` | P2 | `apt:device:list` | 设备离线/指令入队(不含心跳逐条) |
| `REPORT` | P2 | `apt:report:list` | outbox 积压、进入 DEAD |
| `SYSTEM` | P2 | 广播 | 维护通告、配置刷新 |
### 4.3 示例
```json
{
"id": "8f2c9e0d4b7a4c1e9d3f6a2b5c8e1d4f",
"type": "CALL_ALARM",
"category": "ALARM",
"priority": "P0",
"gyid": "APT-A",
"occurredAt": 1794451200123,
"traceId": "7b1f...",
"targetScope": "PERMISSION",
"targetPermission": "apt:jcxx:list",
"data": {
"jcid": "JC-20261112-0001",
"alarmType": "CALL_FAILED",
"detail": "无应答转叫乘报警",
"sound": true
}
}
```
## 5. 推送目标与路由
| scope | 路由规则 | 典型场景 |
| --- | --- | --- |
| `PERMISSION`(默认) | 同 gyid 且权限包含 `targetPermission`(或 `*:*:*`)的会话 | 叫乘、报警、工单、设备 |
| `USER` | 同 gyid 且 `userId` 匹配 | "这条工单派给你"、审核结果回给提交人 |
| `ALL` | 同 gyid 全部在线会话 | 系统维护、配置刷新 |
- 权限点由分类映射(§4.2),也可由调用方显式覆盖。
- 无在线订阅者时只记录 debug 日志,不重试、不落库(WS 不是可靠通道)。
- 同一用户多会话全部投递;会话发送串行化,失败即摘除。
## 6. 推送要点清单(含代码位置)
### 6.1 本轮已接入(4 条)
| # | 触发条件 | 代码位置 | 消息 type / 分类 / 优先级 | 数据内容 | 目标 |
| --- | --- | --- | --- | --- | --- |
| 1 | 值班台命令执行成功(STOP / RE_CALL / FORCE_FINISH / MARK_LEAVE / CANCEL) | `dispatch/service/AptDutyCommandServiceImpl#execute` | `CALL_STATE_CHANGED` / CALL / P1 | `jcid`、`command`、`stateAfter`、`duplicated` | `apt:jcxx:list` |
| 2 | 平板结果受理成功(ANSWERED / NO_ANSWER / FAILED / CANCELLED) | `dispatch/tablet/service/AptTabletResultServiceImpl#accept` | `CALL_STATE_CHANGED` / CALL / P1 | `jcid`、`command="TABLET_RESULT"`、`stateAfter` | `apt:jcxx:list` |
| 3 | 叫乘失败(SIP 超时 / 平板无应答转报警) | `dispatch/service/AptDutyStateServiceImpl#callFailed` | `CALL_ALARM` / ALARM / P0 | `jcid`、`alarmType="CALL_FAILED"`、`detail`、`sound=true` | `apt:jcxx:list` |
| 4 | 平板刷脸未通过 / 跳过(保持待确认) | `AptTabletResultServiceImpl#accept` 的 `UNVERIFIED/SKIPPED` 分支 | `CALL_ALARM` / ALARM / P0 | `jcid`、`alarmType="FACE_UNVERIFIED"`、`detail` | `apt:jcxx:list` |
> `MARK_LEAVE` 归属说明:登记离寓的唯一入口是值班台命令,已由第 1 条覆盖
> (`AptDutyCommandServiceImpl#execute` 调 `AptDutyStateServiceImpl#markLeave` 后于提交前注册 `afterCommit` 推送)。
> 因此 `AptDutyStateServiceImpl#markLeave` **不重复挂点**;若后续出现非命令入口的调用方,
> 再在 `#markLeave` 内补发,并同步去掉命令侧的重复,避免一条离寓推两次。
### 6.2 建议下一批接入(代码已定位,本轮未挂)
| # | 触发条件 | 代码位置 | 建议消息 | 数据内容 | 目标 / 优先级 |
| --- | --- | --- | --- | --- | --- |
| 5 | 离寓超时报警(`is_lybj` 置 1) | `dispatch/mapper/AptDutyStateMapper.xml` 对应更新 + `AptDispatchScanServiceImpl#scan` | `CALL_ALARM` | `jcid`、`roomNo`、`alarmType="LEAVE_ALARM"` | `apt:jcxx:list` / P0 |
| 6 | SIP 事件(入会/挂断/超时) | `voice/service/AptVoiceEventServiceImpl#handle` | `CALL_STATE_CHANGED` | `jcid`、`event`、`channel`、`stateAfter` | `apt:jcxx:list` / P1 |
| 7 | 扫描任务产生超时/补生成 | `dispatch/service/AptDispatchScanServiceImpl#scan` | `SYSTEM_NOTICE` + `CALL_ALARM` | 统计数、受影响 `jcid[]` | `apt:jcxx:list` / P1 |
| 8 | 入住申请提交 | `stay/checkin/service/AptCheckinServiceImpl#apply` | `CHECKIN_APPLIED` | `applyId`、`workNo`、`name`、`roomNo` | `duty:checkin:list` / P1 |
| 9 | 入住审核 / 换房结果 | `AptCheckinServiceImpl#review`、`#roomAdjust` | `CHECKIN_REVIEWED` | `applyId`、`state`、`roomNo`、`operator` | `duty:checkin:list` / P1 |
| 10 | 人脸注册申请待确认 | `face/service/AptFaceRegServiceImpl#apply` | `FACE_REG_APPLIED` | `regId`、`workNo`、`name` | `duty:person:reg` / P1 |
| 11 | 保洁自动建单 / 平板上报待清扫 | `job/service/AptJobServiceImpl#autoCreateCleanOnVacated`、`#reportClean` | `JOB_CREATED` | `taskId`、`roomNo`、`source(AUTO/TABLET)`、`state` | `apt:workorder:list` / P1 |
| 12 | 维修建单 / 派单 / 完成 / 取消 | `AptJobServiceImpl#reportRepair`、`#assignRepair`、`#finishRepair`、`#cancelRepair` | `JOB_STATE_CHANGED` | `orderId`、`roomNo`、`state`、`staffId` | `apt:workorder:list`(派单可 USER 定向)/ P1 |
| 13 | 设备离线(心跳超时) | `device/service/AptDeviceServiceImpl#heartbeat` 的离线判定任务(待补) | `DEVICE_OFFLINE` | `deviceCode`、`deviceType`、`lastHeartbeatAt` | `apt:device:list` / P1 |
| 14 | outbox 积压超阈值 / 进入 DEAD | `report/service/AptReportServiceImpl#stats`、`#requeue`(阈值判定待补) | `REPORT_BACKLOG` | `pending`、`dead`、`lag`、`maxWatermark` | `apt:report:list` / P2 |
| 15 | 房间房态变化(2/5/3/6/4) | `AptJobServiceImpl#syncRoomOccupancy`、`stay/service/AptRoomServiceImpl` | `SYSTEM_NOTICE`(房态) | `roomNo`、`from`、`to`、`reason` | `apt:workorder:list` / P2 |
## 7. 现有模块逐项评估(是否适合实时推送)
| 模块 / 类 | 触发点 | 是否适合 | 结论与理由 |
| --- | --- | --- | --- |
| `AptDutyStateServiceImpl` | 8 个状态迁移方法 | **适合** | 状态机唯一写入口,天然事件源;`callFailed` 已接报警,`markLeave` 由命令侧覆盖(§6.1),其余建议按 §6.2 #5、#6 接入 |
| `AptDutyCommandServiceImpl` | `execute` | **适合(已接)** | 值班员主动操作,需即时反馈 |
| `AptTabletResultServiceImpl` | `accept` | **适合(已接)** | 平板结果到达后值班台须秒级可见 |
| `AptVoiceEventServiceImpl` | `handle`(AMI 事件) | **适合** | SIP 应答/挂断/超时是值班台核心状态源,下一批接入 |
| `AptDispatchScanServiceImpl` | `scan` | **适合(聚合推送)** | 单条超时不必逐条推,聚合为一条统计+受影响清单,避免风暴 |
| `AptCheckinServiceImpl` | `apply` / `review` / `roomAdjust` | **适合** | 待审申请需即时提醒;审核结果回给提交人可 USER 定向 |
| `AptFaceRegServiceImpl` | `apply` / `confirm` / `reject` | **适合** | 值班员确认环节,P1 |
| `AptJobServiceImpl` | 建单/派单/开始/完成/取消、自动建单 | **适合** | 工单是值班台/前台关注点;自动建单必须即时可见 |
| `AptRoomServiceImpl` | 房态编辑/删除/重算 | 部分适合 | 仅在房态实际变化时推 P2;纯配置修改不推 |
| `AptStaffServiceImpl` | 新增/启停/删除 | 不适合 | 低频管理操作,页面刷新即可;启停影响平板由 HTTP 拦截即时生效 |
| `AptDeviceServiceImpl` | `heartbeat` | 部分适合 | 心跳高频,**绝不逐条推**;只推"离线/恢复"跃变事件 |
| `AptDeviceServiceImpl` | `enqueue` | 可选 | 指令入队是后台动作,P2;对值班台价值低 |
| `AptReportServiceImpl` | 每条入队 / 重试 | 不适合 | 量大且非值班关注;改为"积压阈值/DEAD"聚合事件 |
| `AptPersonServiceImpl` | 人员查询/只读拒绝 | 不适合 | 纯查询;只读拒绝是管理端错误提示,HTTP 足够 |
| `AptMasterVersionServiceImpl` | `resync` | 不适合 | 同步进度低频、耗时长,轮询更稳 |
| `AptRouteServiceImpl` | 交路增改停 | 不适合 | 低频配置,页面刷新 |
| `AptSipEndpointServiceImpl` / `AptCallFlowServiceImpl` | 分机、拨号、挂断 | 部分适合 | 分机配置不推;拨号/挂断状态已由状态机覆盖 |
| `AptWorkOrderController` / `AptJcxxController` | 列表/统计/详情 | 不适合 | 纯读接口,前端按需拉取 |
## 8. 实时推送 vs 轮询的边界
| 场景 | 选择 | 理由 |
| --- | --- | --- |
| 叫乘状态、报警、待审入住、工单建单/派单 | **实时** | 秒级影响值班动作,延迟直接转化为业务风险 |
| 列表/统计/详情/时间线 | **轮询或按需** | 数据量大、可复核;WS 只发"有变化"信号,页面再拉 |
| 设备心跳、outbox 每条记录 | **轮询/聚合** | 高频,逐条推送会造成消息风暴与前端渲染压力 |
| 主数据同步进度、自检结果 | **轮询** | 低频长任务,轮询实现简单且可重试 |
| 系统维护通告 | **实时(ALL)** | 需要所有人立即看到 |
原则:**WS 负责"提醒",HTTP 负责"事实"**。前端收到消息后按 `type` 决定是否立即刷新对应列表;刷新失败不影响告警展示。
## 9. 与现有架构的兼容性
| 关注点 | 处理 |
| --- | --- |
| 事务一致性 | `AptRealtimePublisher` 在事务内自动注册 `afterCommit` 投递;回滚不发消息 |
| 业务可靠性 | 推送异常全部 catch + WARN,绝不冒泡影响状态机/工单事务 |
| gyid 隔离 | 握手用部署配置,投递按 `gyid` 过滤,杜绝跨公寓泄露 |
| 权限体系 | 复用若依 `LoginUser.getPermissions()`;新增权限点 `apt:realtime:connect`(3259,已建) |
| 既有接口 | 零改动;WS 关闭时系统行为与当前完全一致 |
| 前端路由 | 不新增页面、不改 `permission.js`;WS 客户端为独立工具模块(待接) |
| 安全 | 令牌走 query 有日志泄露风险,需 Nginx 关闭 query 日志或改用一次性 ticket(见 §10) |
| 集群 | 当前一公寓一进程;多实例需 Redis Pub/Sub 广播,注册表改分布式 |
## 10. 交付物与未就绪
### 10.1 本轮交付
| 项 | 位置 |
| --- | --- |
| 消息信封 / 类型常量 | `framework/realtime/AptRealtimeMessage.java`、`AptRealtimeTypes.java` |
| 会话注册表(权限过滤/广播/定向/摘除/空闲清理) | `AptRealtimeSessionRegistry.java` |
| 握手鉴权(JWT + 权限点 + query token) | `AptRealtimeHandshakeInterceptor.java` |
| 连接处理(CONNECTED / PING-PONG / 断开) | `AptRealtimeHandler.java` |
| 注册配置(`/apt/ws`、Origin、开关) | `AptRealtimeConfig.java` |
| 发布器(afterCommit、失败隔离) | `IAptRealtimePublisher.java`、`AptRealtimePublisher.java` |
| 配置项 | `AptProperties.Realtime`、`apt-defaults.yml`(`APT_REALTIME_*`) |
| 安全放行 / 上下文 | `SecurityConfig`、`AptContextInterceptor` |
| 权限 SQL(已执行) | `30_realtime.sql`(3259,超管已绑定) |
| 单元测试 | `AptRealtimeTest`(5 例:路由/权限/广播/关闭摘除/超管) |
验证:`mvn -o -pl ruoyi-apt -am test` → 251 passed(246 + 5)。
### 10.2 未就绪
| # | 未就绪项 | 影响 | 建议 |
| --- | --- | --- | --- |
| 1 | 前端 WS 客户端(`RuoYi-Vue2`) | 值班台尚不能实际接收 | 新增 `src/utils/ws.js`:连接/心跳/退避重连/按 type 分发 |
| 2 | 前端消息中心(声音、角标、置顶) | P0 报警无声音提示 | 与值班台页面一起做 |
| 3 | 下一批推送点(§6.2 #5~#15) | 只覆盖叫乘命令/平板结果/报警 | 按业务优先级分批接入 |
| 4 | 多实例广播 | 仅单进程有效 | 一公寓一进程下不阻塞;扩容前换 Redis Pub/Sub |
| 5 | 空闲会话定时清理 | 半开连接依赖 TCP/代理超时 | 挂 Quartz 每 30s 调用 `sweepIdle` |
| 6 | 令牌 query 泄露防护 | 日志可能记录 token | Nginx 对该路径关闭 query 日志,或改一次性 ticket |
| 7 | 压测与断网演练 | 并发连接数/重连风暴未验证 | 试点前 500 连接 + 断网 10 分钟演练 |
| 8 | WS 消息审计 | 目前只有业务审计,无推送审计 | 如需取证,落 `duty_operation_log`(量可控时) |
## 11. 前端接入约定(供实现方)
```js
// 伪代码:连接 → 心跳 → 重连 → 分发
const ws = new WebSocket(`${wsBase}/apt/ws?token=${encodeURIComponent(token)}`)
ws.onmessage = e => {
const msg = JSON.parse(e.data)
if (msg.type === 'CONNECTED') startHeartbeat(msg.data.heartbeatIntervalSec)
if (msg.type === 'PONG') return
if (msg.type === 'CALL_ALARM') playAlarm() // P0:声音 + 置顶
if (msg.category === 'CALL') refreshJcxxList() // P1:拉快照
if (msg.category === 'JOB') refreshWorkOrders()
}
```
- 重连成功后必须重新拉一次快照,WS 不补发历史消息。
- `type=CONNECTED` 是唯一可用的"连接就绪"信号;不要依赖 `onopen` 判断鉴权结果。