排查 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:
@@ -17,6 +17,7 @@ import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
// API 和状态管理
|
||||
import {
|
||||
getCurrentProblemFlowchartSubmission,
|
||||
getFlowchartSubmission,
|
||||
getFlowchartSubmissionDetail,
|
||||
submitFlowchart,
|
||||
} from "oj/api"
|
||||
@@ -87,30 +88,129 @@ function splitSuggestionLines(suggestions?: string | null) {
|
||||
: []
|
||||
}
|
||||
|
||||
// ==================== 评分结果监听 ====================
|
||||
/**
|
||||
* 评分结果有两条路进来:WebSocket 推送(快)和轮询(稳)。谁先到谁结算,
|
||||
* 靠 monitoringId 去重 —— 结算时清空,另一条路后到就直接跳过。
|
||||
*
|
||||
* 之所以必须有轮询兜底:Redis pub/sub 是发完不管的,worker 推的那一刻只要这条
|
||||
* 连接不在(重连空窗、页面刚从后台切回来),这条消息就永远丢了。判题那边一直
|
||||
* 有兜底轮询,流程图这边没有,丢一次消息按钮就一直转圈转到用户自己刷新。
|
||||
*/
|
||||
const monitoringId = ref("")
|
||||
|
||||
/** AI 评分比判题慢得多,轮询放缓一点 */
|
||||
const POLL_INTERVAL = 3000
|
||||
/** 到点还没结果就收手,别无限轮询下去 */
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||
|
||||
type Outcome =
|
||||
| { ok: true; score: number; grade: string }
|
||||
| { ok: false; error?: string }
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
if (!monitoringId.value) {
|
||||
pausePolling()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getFlowchartSubmission(monitoringId.value)
|
||||
if (data.status === 2) {
|
||||
settle(data.id, {
|
||||
ok: true,
|
||||
score: data.aiScore ?? 0,
|
||||
grade: data.aiGrade ?? "",
|
||||
})
|
||||
} else if (data.status === 3) {
|
||||
settle(data.id, { ok: false })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Flowchart] 轮询失败:", error)
|
||||
pausePolling()
|
||||
}
|
||||
},
|
||||
POLL_INTERVAL,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// WebSocket 正常时压根用不上轮询,先给它 5 秒,到点还没结果才开始拉
|
||||
const { start: startPollingFallback, stop: stopPollingFallback } = useTimeoutFn(
|
||||
() => {
|
||||
if (monitoringId.value) resumePolling()
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
const { start: startPollingDeadline, stop: stopPollingDeadline } = useTimeoutFn(
|
||||
() => {
|
||||
if (!monitoringId.value) return
|
||||
monitoringId.value = ""
|
||||
unsubscribe()
|
||||
pausePolling()
|
||||
loading.value = false
|
||||
message.warning("评分等待超时,请稍后刷新页面查看结果")
|
||||
},
|
||||
POLL_TIMEOUT,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
function settle(submissionId: string, outcome: Outcome) {
|
||||
// 一个学生可能同时开着几道题的页面,每条连接都订在同一个用户 topic 上,
|
||||
// 别的页面的评分结果照样会推到这里来 —— 必须认 id,不然会张冠李戴
|
||||
if (!submissionId || submissionId !== monitoringId.value) return
|
||||
monitoringId.value = ""
|
||||
unsubscribe()
|
||||
pausePolling()
|
||||
stopPollingFallback()
|
||||
stopPollingDeadline()
|
||||
loading.value = false
|
||||
|
||||
if (!outcome.ok) {
|
||||
message.error(
|
||||
outcome.error
|
||||
? `流程图评分失败: ${outcome.error}`
|
||||
: "流程图评分失败,请稍后重试",
|
||||
)
|
||||
return
|
||||
}
|
||||
latestRating.value = { score: outcome.score, grade: outcome.grade }
|
||||
message.success(
|
||||
`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`,
|
||||
)
|
||||
if (
|
||||
(outcome.grade === "A" || outcome.grade === "S") &&
|
||||
lastSubmittedMermaidCode.value
|
||||
) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WebSocket 相关函数 ====================
|
||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
if (data.type === "flowchart_evaluation_completed") {
|
||||
loading.value = false
|
||||
const grade = data.grade || ""
|
||||
latestRating.value = { score: data.score || 0, grade }
|
||||
message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
|
||||
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
settle(data.submissionId, {
|
||||
ok: true,
|
||||
score: data.score ?? 0,
|
||||
grade: data.grade || "",
|
||||
})
|
||||
} else if (data.type === "flowchart_evaluation_failed") {
|
||||
loading.value = false
|
||||
message.error(`流程图评分失败: ${data.error}`)
|
||||
settle(data.submissionId, { ok: false, error: data.error })
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
const { connect, disconnect, subscribe } = useFlowchartWebSocket(
|
||||
const { connect, disconnect, subscribe, unsubscribe } = useFlowchartWebSocket(
|
||||
handleWebSocketMessage,
|
||||
)
|
||||
|
||||
// 订阅提交更新
|
||||
// 订阅提交更新,同时开启轮询兜底
|
||||
function subscribeToSubmission(submissionId: string) {
|
||||
monitoringId.value = submissionId
|
||||
subscribe(submissionId)
|
||||
startPollingFallback()
|
||||
startPollingDeadline()
|
||||
}
|
||||
|
||||
// ==================== 提交相关函数 ====================
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed, watch, onUnmounted } from "vue"
|
||||
import { ref, computed, onUnmounted } from "vue"
|
||||
import { useIntervalFn, useTimeoutFn } from "@vueuse/core"
|
||||
import { getSubmission } from "oj/api"
|
||||
import { SubmissionStatus } from "utils/constants"
|
||||
@@ -81,6 +81,8 @@ export function useSubmissionMonitor() {
|
||||
|
||||
// 停止轮询(WebSocket已成功)
|
||||
pausePolling()
|
||||
// 结果已经到手,别让重连再去重放这条早就判完的订阅
|
||||
unsubscribe()
|
||||
|
||||
getSubmission(submissionId.value).then((res) => {
|
||||
submission.value = res
|
||||
@@ -94,9 +96,9 @@ export function useSubmissionMonitor() {
|
||||
const {
|
||||
connect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
scheduleDisconnect,
|
||||
cancelScheduledDisconnect,
|
||||
status: wsStatus,
|
||||
} = useSubmissionWebSocket(handleSubmissionUpdate)
|
||||
|
||||
// ==================== 轮询保底启动 ====================
|
||||
@@ -124,27 +126,17 @@ export function useSubmissionMonitor() {
|
||||
// 取消之前的断开计划
|
||||
cancelScheduledDisconnect()
|
||||
|
||||
// 如果WebSocket未连接,先连接
|
||||
if (wsStatus.value !== "connected") {
|
||||
console.log("[SubmissionMonitor] 启动WebSocket连接...")
|
||||
connect()
|
||||
}
|
||||
// connect() 是幂等的:已经连着就直接返回,顺带把上一次空闲断开留下的状态清掉
|
||||
connect()
|
||||
|
||||
// 等待WebSocket连接并订阅
|
||||
let unwatch: (() => void) | null = null
|
||||
unwatch = watch(
|
||||
wsStatus,
|
||||
(status) => {
|
||||
if (status === "connected") {
|
||||
console.log("[SubmissionMonitor] WebSocket已连接,订阅提交:", id)
|
||||
subscribe(id)
|
||||
if (unwatch) {
|
||||
unwatch() // 订阅成功后停止监听
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
// 直接订阅,不必先等 status 变成 connected:连接没就绪时 subscribe() 会把 id
|
||||
// 记在 pendingSubmissionId 上,由 onConnected() 补发(断线重连后同样有效)。
|
||||
//
|
||||
// 原来这里是 watch(wsStatus, ..., { immediate: true }),而 immediate 的回调在
|
||||
// watch() **返回之前**就同步跑了 —— 已经连着时 unwatch 还是 null,if 不成立,
|
||||
// 这个 watcher 就永远停不掉:每提交一次泄漏一个,而且往后每次重连时它们都会
|
||||
// 把各自那个早就判完的旧 submissionId 重新订阅一遍。
|
||||
subscribe(id)
|
||||
|
||||
// 5秒后启动轮询保底(防止WebSocket失败)
|
||||
startPollingFallback()
|
||||
|
||||
Reference in New Issue
Block a user