排查 WS 这一块时发现的一批问题,多数是「机制写了但从没生效过」。
## 重连
`disconnect()` 里 `enableAutoReconnect = false`,而 `connect()` 从不改回 true ——
登出再登录后,这条连接就永远失去了自动重连能力(configUpdate 那条 watch 上尤其
明显)。改成用 `closedByUser` 表达「用户主动断开」的意图,和 `enableAutoReconnect`
这个**配置**分开。
`scheduleDisconnect` 的回调里断完紧接着一句 `enableAutoReconnect = true`,而
close 是异步的 —— 等 onclose 跑到时标志已经翻回来了,1 秒后又自动连上。那个
「15 分钟空闲省资源」从来没真正断开过。现在只断开,不做事后翻转。
重连的 setTimeout 没存句柄,组件卸载后照样触发 `connect()`,在已销毁的组件上
又建一条连接。现在 `disconnect()` 里 clearTimeout。
退避从「线性 ×5 次」改成「指数 + 抖动、30 秒封顶、次数不封顶」。原来 1+2+3+4+5
只有 15 秒,后端 deploy 重启一次就超了,之后这条连接死到用户刷新为止。抖动是
为了避免一个班几十台机器在同一毫秒一起冲回刚起来的后端。另挂 online /
visibilitychange,网络恢复或切回标签页立刻重连,不必等退避走完。
所有 socket 回调改成闭包住局部 ws 并在入口 `if (ws !== this.ws) return`,旧连接
迟到的 onclose 不再污染新连接的状态 —— 也是让 `disconnect()` 能被 onclose 识别
出来的关键。
## 订阅重放
`pendingSubmissionId` 一发送成功就清空,它只解决了「还没连上就 subscribe」,
**没解决断线重连**。而真正会丢结果的恰恰是后者:服务端收到 subscribe 会回一份
当前状态,掉线期间错过的推送就是靠这次重放补回来的;不重新订阅,重连后只收得到
「将来」的事件,可结果已经是过去式了。改成订阅意图保留到显式 `unsubscribe()`,
每次 onConnected 都重发。
这套逻辑原来只有 SubmissionWebSocket 有,FlowchartWebSocket 是 send 失败打一行
日志了事 —— socket 一掉,那次评分结果就再也回不来,按钮一直转圈。提成公共基类
SubscribingWebSocket,两条通道共用。
流程图另加轮询兜底:提交后 5 秒 WS 还没出结果就每 3 秒拉一次,读 status 2/3
结算,3 分钟上限。判题那边一直有兜底,流程图这边没有,而 Redis pub/sub 是发完
不管的,worker 推的那一刻连接不在就永远丢了。
`useSubmissionMonitor` 里 `watch(wsStatus, ..., { immediate: true })` 的回调在
watch() **返回之前**就同步跑了,已经连着时 `unwatch` 还是 null,if 不成立,
watcher 永远停不掉:每提交一次泄漏一个,往后每次重连它们都会把各自那个早就判完
的旧 submissionId 重新订阅一遍。整块删掉,直接 subscribe —— 基类已经管了时序。
WS handler 原来不校验 submissionId。学生同时开着几道题的页面时,每条连接都订在
同一个用户 topic 上,别的页面的评分结果会被当成自己的。
## 会话
握手时校验过一次会话就再也不管了,这条连接却能挂几个小时:用户在别的标签页
登出、或者会话本身到期,旧 socket 照样收推送。加一条 60 秒一轮的巡检,用
Redis EXPIRE 一条命令同时完成「判断存在」和「续期」(续期是必要的:只开着页面
挂 WS 的人一次 HTTP 请求都不发,不该被算成不活跃踢下线)。Redis 抛错时整轮
放弃,绝不因为一次抖动把全班踢下线。
禁用只改数据库的 isDisabled 列、不动 Redis 里的会话,巡检永远发现不了。加
`session:revoked` 频道主动通知,**两种作用域不能混**:
{ token } 用户登出。只断这一张会话 —— 同一个人在别的设备上是另一张
会话,按 userId 广播会把他手机上的登录一起踢掉
{ userId } 账号被禁用。所有设备都得断
先发一帧 force_logout 再隔 100ms 断开。只断不发的话前端只看到一次普通掉线,
会照常重连、页面上还显示着登录态。token 不进帧里 —— 那是 httpOnly cookie 的值,
推到 WS 上就等于交给了 JS,匹配全在服务端做。
前端在协议层拦截 force_logout(和 pong 一样,不下发给业务 handler),并主动
disconnect —— 否则会一路 401 重连到退避上限,正是这机制要消掉的浪费。表现刻意
和 utils/api.ts 里 account-disabled / login-required 两支保持一致:同一件事从
HTTP 和 WS 两条路进来,学生看到的不该有两个样子。
## 开销与安全
`void handleMessage(...)` 是裸的,里面有两次 DB 查询和一个会抛的 schema.parse,
库抖一下就是一个 unhandled rejection(隔壁 bridgeSubmissionEvents 两处都接住了,
只有这里漏了)。
ping 提到用户查询之前。原来的顺序是「先查 user 再看消息类型」,每个客户端每
30 秒都要为一次心跳打一趟数据库。禁用用户不会因此漏网:推送路径上 bridge 会查,
subscribe 这条真正读数据的路径下面照样查。
bridge 两条 per-user 通道都先看 `server.subscriberCount(topic)`,没人订阅就别
查库了 —— 判题高峰期绝大多数事件的目标用户此刻并不在线。
flowchart 评分失败原来把 error.message 原样推给学生、前端直接弹出来,AI provider
的地址和内部报错就这么进了浏览器。改成真实原因写服务端日志。
加每连接令牌桶(20 突发 + 每秒回填 2)。一条 subscribe 在服务端是一到两次数据库
查询,一个学生开着一条 socket 狂发就能压住库。正常流量离阈值几十倍远。
升级时校验 Origin。会话 cookie 是 SameSite=Lax、WS 握手不是导航,跨站页面本来就
带不上 cookie,所以这是防御纵深不是唯一防线。同源放行;本机开发(Vite 5173 →
API 3000)自动放行,且只在两边都是本机时成立 —— 生产环境 url.hostname 是正式
域名,这条永远不触发;跨域部署走 ALLOWED_WS_ORIGINS。不发 Origin 的一律放行:
真正的攻击面是带着受害者 cookie 的浏览器页面,而浏览器一定会带 Origin。
顺带:useConfigWebSocket 的 handler 从 onMounted 挪到同步注册(调用方在 setup
阶段就 connect() 了),删掉每条消息打完整内容的 console.log 和死字段
ws.data.username。
## 没动的
题目页上并没有两条 /ws/submissions —— Form.vue 里 SubmitFlowchart 和 SubmitCode
是 v-if/v-else,互斥。学生实际是 2 条连接:全站一条 /ws/config + 一条
/ws/submissions,正常,不必合并。
## 验证
55 个用例,分六组打桩跑(假 WebSocket + 假计时器;会话/限流/吊销三组对着真
Redis):
重连语义 7 断开后不再自我复活、卸载后计时器已取消、退避封顶、
online 立即重连、旧 onclose 不污染新连接
订阅重放 9 未就绪时补发、重连后重新订阅、unsubscribe 后不再重放
会话巡检 8 EXPIRE 三态、只断失效的、同 token 只查一次、
Redis 抖动时一个都不踢
Origin/限流 13 跨站与跨端口拒绝、生产不因 localhost 开后门、
突发额度、按时间回填
强制登出 13 登出只断同 token(别的设备不受牵连)、禁用断所有设备、
巡检先通知再断
前端登出 5 两支表现、收到后不再重连、两条通道只处理一次
前两组做了改动前/后对比,老代码该挂的都挂了 —— 「重连后自动重新订阅」正是这么
跑出来的,此前我以为 pendingSubmissionId 已经覆盖了这种情况。
apps/api 的 tsc 和 apps/web 的 vue-tsc 都干净,仓库既有测试照常通过。
**SubmitFlowchart.vue 的轮询兜底未经运行时验证** —— 在 SFC 内,没搭组件挂载
环境,只过了类型检查和人工核对。要验的话,停掉 worker 提交一次流程图,看 5 秒
后是否转入轮询、3 分钟后是否给出超时提示。
这批改动动了 WS 的行为面(限流会断连接、Origin 会拒绝、巡检会踢会话),上线前
建议手测:提交代码看判题、切流程图看评分、后台改配置看全站生效、开两个标签页
在一个里登出、禁用一个在线学生看另一端反应。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,37 @@
|
||||
import { createDiscreteApi } from "naive-ui"
|
||||
import { ref, onUnmounted, type Ref } from "vue"
|
||||
|
||||
import { useAuthModalStore } from "shared/store/authModal"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
|
||||
// 全站唯一一处,脱离 n-message-provider 也能弹 —— 强制登出跟当前挂着哪个组件无关。
|
||||
// utils/api.ts 里也是这么做的
|
||||
const { message: toast } = createDiscreteApi(["message"])
|
||||
|
||||
/**
|
||||
* 服务端要求下线。两种来源:账号被管理员禁用,或者这张会话没了(在别的标签页
|
||||
* 登出、或者会话到期)。
|
||||
*
|
||||
* 表现刻意和 utils/api.ts 里 account-disabled / login-required 两支保持一致 ——
|
||||
* 同一件事从 HTTP 和 WebSocket 两条路进来,学生看到的结果不该有两个样子。
|
||||
*/
|
||||
function handleForceLogout(reason: string) {
|
||||
const userStore = useUserStore()
|
||||
// 配置通道和提交通道可能同时挂着,两条都会收到这一帧。第一次就把登录态清了,
|
||||
// 第二次在这里掉头,免得弹两遍
|
||||
if (!userStore.isAuthed) return
|
||||
storage.remove(STORAGE_KEY.AUTHED)
|
||||
userStore.clearProfile()
|
||||
if (reason === "account-disabled") {
|
||||
// 不能弹登录框:账号已经禁用,登进去还是被拒,会陷进「弹框 → 登录 → 又弹框」
|
||||
toast.error("账号已被禁用,请联系老师")
|
||||
return
|
||||
}
|
||||
useAuthModalStore().openLoginModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 连接状态
|
||||
*/
|
||||
@@ -20,10 +52,16 @@ export interface WebSocketMessage {
|
||||
export interface WebSocketConfig {
|
||||
/** 完整 URL。后端只认 /ws/submissions 和 /ws/config 两条,按当前页面的协议与 host 拼 */
|
||||
url: string
|
||||
/** 最大重连次数,默认 5 */
|
||||
/**
|
||||
* 最大重连次数,默认不限。
|
||||
* 原来默认 5 次、线性退避,加起来只有 15 秒 —— 后端 deploy 重启一次就超了,
|
||||
* 之后这条连接死到用户刷新页面为止。机房网络抖动同理,所以默认不再封顶。
|
||||
*/
|
||||
maxReconnectAttempts?: number
|
||||
/** 重连延迟(毫秒),默认 1000 */
|
||||
/** 首次重连延迟(毫秒),默认 1000。之后指数退避 */
|
||||
reconnectDelay?: number
|
||||
/** 重连延迟上限(毫秒),默认 30000 */
|
||||
maxReconnectDelay?: number
|
||||
/** 心跳间隔(毫秒),默认 30000(30秒) */
|
||||
heartbeatTime?: number
|
||||
/** 是否启用心跳,默认 true */
|
||||
@@ -54,15 +92,25 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
protected heartbeatTime: number
|
||||
protected enableHeartbeat: boolean
|
||||
protected enableAutoReconnect: boolean
|
||||
protected maxReconnectDelay: number
|
||||
protected disconnectTimer: number | null = null
|
||||
protected reconnectTimer: number | null = null
|
||||
/**
|
||||
* 「用户主动断开」的意图,和 enableAutoReconnect 这个**配置**分开存。
|
||||
* 以前两者共用一个字段:disconnect() 把配置改成 false 来阻止重连,而 connect()
|
||||
* 从不改回 true —— 登出再登录后,这条连接就永远失去了自动重连能力。
|
||||
*/
|
||||
protected closedByUser = false
|
||||
protected reviveBound = false
|
||||
|
||||
public status: Ref<ConnectionStatus> = ref<ConnectionStatus>("disconnected")
|
||||
|
||||
constructor(config: WebSocketConfig) {
|
||||
this.url = config.url
|
||||
|
||||
this.maxReconnectAttempts = config.maxReconnectAttempts ?? 5
|
||||
this.maxReconnectAttempts = config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY
|
||||
this.reconnectDelay = config.reconnectDelay ?? 1000
|
||||
this.maxReconnectDelay = config.maxReconnectDelay ?? 30000
|
||||
this.heartbeatTime = config.heartbeatTime ?? 30000
|
||||
this.enableHeartbeat = config.enableHeartbeat ?? true
|
||||
this.enableAutoReconnect = config.enableAutoReconnect ?? true
|
||||
@@ -72,6 +120,10 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
* 连接 WebSocket
|
||||
*/
|
||||
connect() {
|
||||
// 重新表达「我要连着」的意图:把上一次 disconnect() 留下的状态清掉
|
||||
this.closedByUser = false
|
||||
this.clearReconnectTimer()
|
||||
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
@@ -80,12 +132,17 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindReviveListeners()
|
||||
this.status.value = "connecting"
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url)
|
||||
// 所有回调都闭包住这个局部 ws 而不是读 this.ws:一条被换掉的旧连接
|
||||
// 迟到的 onclose / onerror 不该去改现在这条连接的状态
|
||||
const ws = new WebSocket(this.url)
|
||||
this.ws = ws
|
||||
|
||||
this.ws.onopen = () => {
|
||||
ws.onopen = () => {
|
||||
if (ws !== this.ws) return
|
||||
this.status.value = "connected"
|
||||
this.reconnectAttempts = 0
|
||||
console.log(`[WebSocket] 连接成功: ${this.url}`)
|
||||
@@ -95,16 +152,26 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
this.onConnected()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
ws.onmessage = (event) => {
|
||||
if (ws !== this.ws) return
|
||||
try {
|
||||
const data = JSON.parse(event.data) as T
|
||||
console.log(`[WebSocket] 收到消息:`, data)
|
||||
|
||||
// 处理心跳响应
|
||||
if (data.type === "pong") {
|
||||
return
|
||||
}
|
||||
|
||||
// 服务端要求下线。和 pong 一样是协议层的事,不该让每个业务 handler
|
||||
// 各自认一遍 —— 而且此刻多半根本没有能处理它的 handler 挂着
|
||||
if (data.type === "force_logout") {
|
||||
// 必须主动断,否则服务端断开后这条连接会照常自动重连,然后一路 401
|
||||
// 撞到退避上限 —— 正是这条机制要消掉的浪费
|
||||
this.disconnect()
|
||||
handleForceLogout(String(data.reason ?? ""))
|
||||
return
|
||||
}
|
||||
|
||||
// 调用消息处理钩子
|
||||
this.onMessage(data)
|
||||
} catch (error) {
|
||||
@@ -112,50 +179,112 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
ws.onerror = (error) => {
|
||||
if (ws !== this.ws) return
|
||||
console.error("[WebSocket] 连接错误:", error)
|
||||
this.status.value = "error"
|
||||
this.onError(error)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
ws.onclose = (event) => {
|
||||
// disconnect() 会先把 this.ws 置空再 close(),所以主动断开走不到这里,
|
||||
// 收尾由 disconnect() 自己做 —— 也就不会再像以前那样「断完立刻重连」
|
||||
if (ws !== this.ws) return
|
||||
this.ws = null
|
||||
console.log(
|
||||
`[WebSocket] 连接关闭: code=${event.code}, reason=${event.reason}`,
|
||||
)
|
||||
this.status.value = "disconnected"
|
||||
this.stopHeartbeat()
|
||||
this.onDisconnected(event)
|
||||
|
||||
// 自动重连
|
||||
if (
|
||||
this.enableAutoReconnect &&
|
||||
this.reconnectAttempts < this.maxReconnectAttempts
|
||||
) {
|
||||
this.reconnectAttempts++
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts
|
||||
console.log(
|
||||
`[WebSocket] 将在 ${delay}ms 后重连 (尝试 ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
)
|
||||
setTimeout(() => this.connect(), delay)
|
||||
}
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create WebSocket connection:", error)
|
||||
this.status.value = "error"
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排一次重连。指数退避 + 抖动:一个班几十台机器同时掉线时,
|
||||
* 别在同一毫秒一起冲回来把刚起来的后端再压趴一次。
|
||||
*/
|
||||
protected scheduleReconnect() {
|
||||
if (this.closedByUser || !this.enableAutoReconnect) return
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return
|
||||
if (this.reconnectTimer !== null) return
|
||||
|
||||
this.reconnectAttempts++
|
||||
const base = Math.min(
|
||||
this.reconnectDelay * 2 ** (this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay,
|
||||
)
|
||||
const delay = Math.round(base * (0.5 + Math.random() * 0.5))
|
||||
console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
protected clearReconnectTimer() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络恢复 / 标签页重新可见时立刻重连,不必等退避计时器走完。
|
||||
* 退避到 30 秒后,用户切回页面却还要再干等半分钟是说不过去的。
|
||||
*/
|
||||
protected readonly revive = () => {
|
||||
if (this.closedByUser || !this.enableAutoReconnect) return
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === "hidden") return
|
||||
if (navigator.onLine === false) return
|
||||
this.clearReconnectTimer()
|
||||
this.reconnectAttempts = 0
|
||||
this.connect()
|
||||
}
|
||||
|
||||
protected bindReviveListeners() {
|
||||
if (this.reviveBound) return
|
||||
this.reviveBound = true
|
||||
window.addEventListener("online", this.revive)
|
||||
document.addEventListener("visibilitychange", this.revive)
|
||||
}
|
||||
|
||||
protected unbindReviveListeners() {
|
||||
if (!this.reviveBound) return
|
||||
this.reviveBound = false
|
||||
window.removeEventListener("online", this.revive)
|
||||
document.removeEventListener("visibilitychange", this.revive)
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
this.closedByUser = true
|
||||
this.cancelScheduledDisconnect()
|
||||
// 以前没存重连计时器的句柄:卸载后那个 setTimeout 照样会触发 connect(),
|
||||
// 在已经销毁的组件上又建一条连接出来
|
||||
this.clearReconnectTimer()
|
||||
this.stopHeartbeat()
|
||||
this.enableAutoReconnect = false // 停止自动重连
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.unbindReviveListeners()
|
||||
this.reconnectAttempts = 0
|
||||
// 先摘掉引用再 close(),onclose 里的 `ws !== this.ws` 就能识别出这是主动断开
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
if (ws) ws.close()
|
||||
this.status.value = "disconnected"
|
||||
}
|
||||
|
||||
@@ -169,11 +298,14 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
|
||||
// 设置新的定时器
|
||||
this.disconnectTimer = window.setTimeout(() => {
|
||||
this.disconnectTimer = null
|
||||
const minutes = Math.floor(delay / 60000)
|
||||
console.log(`WebSocket idle for ${minutes} minutes, disconnecting...`)
|
||||
// 这里**只断开**。原来断完紧接着一句 `enableAutoReconnect = true`,
|
||||
// 而 close 是异步的 —— 等 onclose 跑到时标志已经翻回来了,于是 1 秒后
|
||||
// 又自动连上:这个「省资源」的空闲断开从来没有真正生效过。
|
||||
// 下一次 connect()(新提交)会自己把 closedByUser 清掉,不需要在这里预置。
|
||||
this.disconnect()
|
||||
// 断开后需要重新允许自动重连
|
||||
this.enableAutoReconnect = true
|
||||
}, delay)
|
||||
}
|
||||
|
||||
@@ -294,39 +426,57 @@ export interface SubmissionUpdate extends WebSocketMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 WebSocket 连接管理类
|
||||
* 带「订阅意图」的连接。
|
||||
*
|
||||
* subscribe() 在连接还没就绪时先把 id 记下来,等 onConnected() 补发 —— 调用方
|
||||
* 不用关心此刻连上没有,断线重连后也会自动重新订阅。
|
||||
*
|
||||
* 原来这套只有 SubmissionWebSocket 有,FlowchartWebSocket 是 send 失败就打一行
|
||||
* 日志了事:socket 一掉,那次评分的结果就再也回不来,页面永远转圈。提到基类上,
|
||||
* 两条通道共用同一套语义。
|
||||
*/
|
||||
class SubmissionWebSocket extends BaseWebSocket<SubmissionUpdate> {
|
||||
private pendingSubmissionId = ""
|
||||
|
||||
constructor() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
super({ url: `${protocol}//${window.location.host}/ws/submissions` })
|
||||
}
|
||||
class SubscribingWebSocket<
|
||||
T extends WebSocketMessage,
|
||||
> extends BaseWebSocket<T> {
|
||||
/**
|
||||
* 当前在等结果的提交。**一直留着**,直到调用方 unsubscribe()。
|
||||
*
|
||||
* 原来这个字段叫 pendingSubmissionId,订阅一发成功就清空 —— 它只解决了
|
||||
* 「还没连上就调 subscribe」,没解决断线重连。而真正会丢结果的恰恰是后者:
|
||||
* 服务端收到 subscribe 会回一份当前状态,掉线期间错过的那条推送就是靠这次
|
||||
* 重放补回来的。不重新订阅,重连后就只收得到「将来」的事件,可结果已经是过去式了。
|
||||
*/
|
||||
private subscribedId = ""
|
||||
|
||||
/**
|
||||
* 订阅特定提交的更新
|
||||
* 订阅特定提交的更新。连接没就绪也可以调,连上后会自动补发。
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
this.pendingSubmissionId = submissionId
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submissionId,
|
||||
})
|
||||
if (success) this.pendingSubmissionId = ""
|
||||
this.subscribedId = submissionId
|
||||
this.sendSubscribe(submissionId)
|
||||
}
|
||||
|
||||
/** 结果已经拿到,重连后不必再问一遍 */
|
||||
unsubscribe() {
|
||||
this.subscribedId = ""
|
||||
}
|
||||
|
||||
protected onConnected() {
|
||||
if (!this.pendingSubmissionId) return
|
||||
const submissionId = this.pendingSubmissionId
|
||||
if (
|
||||
this.send({
|
||||
type: "subscribe",
|
||||
submissionId,
|
||||
})
|
||||
) {
|
||||
this.pendingSubmissionId = ""
|
||||
}
|
||||
if (this.subscribedId) this.sendSubscribe(this.subscribedId)
|
||||
}
|
||||
|
||||
private sendSubscribe(submissionId: string) {
|
||||
return this.send({ type: "subscribe", submissionId })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交 WebSocket 连接管理类
|
||||
*/
|
||||
class SubmissionWebSocket extends SubscribingWebSocket<SubmissionUpdate> {
|
||||
constructor() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
super({ url: `${protocol}//${window.location.host}/ws/submissions` })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +506,7 @@ export function useSubmissionWebSocket(
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
unsubscribe: () => ws.unsubscribe(),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
@@ -438,24 +589,11 @@ export interface FlowchartEvaluationUpdate extends WebSocketMessage {
|
||||
/**
|
||||
* 流程图 WebSocket 连接管理类
|
||||
*/
|
||||
class FlowchartWebSocket extends BaseWebSocket<FlowchartEvaluationUpdate> {
|
||||
class FlowchartWebSocket extends SubscribingWebSocket<FlowchartEvaluationUpdate> {
|
||||
constructor() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||||
super({ url: `${protocol}//${window.location.host}/ws/submissions` })
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅特定流程图提交的更新
|
||||
*/
|
||||
subscribe(submissionId: string) {
|
||||
const success = this.send({
|
||||
type: "subscribe",
|
||||
submissionId,
|
||||
})
|
||||
if (!success) {
|
||||
console.error("[Flowchart WebSocket] 订阅失败: 连接未就绪")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -483,6 +621,7 @@ export function useFlowchartWebSocket(
|
||||
connect: () => ws.connect(),
|
||||
disconnect: () => ws.disconnect(),
|
||||
subscribe: (submissionId: string) => ws.subscribe(submissionId),
|
||||
unsubscribe: () => ws.unsubscribe(),
|
||||
scheduleDisconnect: (delay?: number) => ws.scheduleDisconnect(delay),
|
||||
cancelScheduledDisconnect: () => ws.cancelScheduledDisconnect(),
|
||||
status: ws.status,
|
||||
@@ -521,11 +660,12 @@ class ConfigWebSocket extends BaseWebSocket<ConfigUpdate> {
|
||||
export function useConfigWebSocket(handler?: MessageHandler<ConfigUpdate>) {
|
||||
const ws = new ConfigWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
})
|
||||
// 同步注册,和另外两个 composable 一致。原来放在 onMounted 里,而调用方
|
||||
// (useConfigUpdate)在 setup 阶段就 connect() 了 —— 中间那段窗口收到的广播
|
||||
// 没有任何 handler 接。窗口极小,但没有任何理由留着它。
|
||||
if (handler) {
|
||||
ws.addHandler(handler)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handler) {
|
||||
|
||||
Reference in New Issue
Block a user