From ab8fcc2d42cea78539993fedffef7ad135cede15 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 28 Aug 2026 06:01:41 -0600 Subject: [PATCH] =?UTF-8?q?fix(collab):=20room=5Fclosed=20=E6=97=A0?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E5=BD=92=E4=BD=8D=E3=80=81=E6=96=AD=E7=BA=BF?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E8=A1=A5=E7=8A=B6=E6=80=81=E3=80=81=E5=AD=A6?= =?UTF-8?q?=E7=94=9F=20socket=20=E9=87=8D=E8=BF=9E=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical:room_closed 不再按 reason 挑着重置 helpStatus——学生自己的 socket 发送失败时服务端删了请求却发不出任何纠正帧,store 会卡在陈旧的 active/pending 上出不来。现在一律先归 idle,老师掉线那条路径服务端会紧跟着补一条 help_status:pending,同一条连接消息严格按序到达,不会被这次重置抢跑。 Critical:断线重连没有补状态。前端 CollabWebSocket 加 onConnected 钩子, 每次连接建立(含重连)都清空本地 requests/helpStatus/room,等服务端补发; 后端 handleCollabOpen 对非老师的重连方,如果这个账号名下还有请求, 补发对应的 help_status(pending 带重算的 queueAhead,active 带 teacherName)。 Critical:重连后请求仍绑在旧 socket 上,导致 handleHelpCancel 的归属校验 认不出新连接、后续通知也写进死连接。handleCollabOpen 里把请求和(如果有) 房间迁移到新 socket,并清掉旧 socket 的 roomOwnerId,防止它稍后的 close 反过来拆掉刚迁移出去的房间。 Minor:disconnect() 补齐 queueAhead/teacherName/notice 的重置,不留陈旧值。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- apps/api/src/collab/handler.ts | 26 ++++++++++++++++++++ apps/web/src/shared/composables/websocket.ts | 15 +++++++++++ apps/web/src/shared/store/collab.ts | 26 ++++++++++++++++++-- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 603567f..cb915f5 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -63,6 +63,32 @@ export function handleCollabOpen(ws: CollabSocket) { ws.send( JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), ) + return + } + + // 学生(重)连:如果这个账号名下已经有一条请求(掉线重连回来,或者干脆是 + // 同账号第二个标签页),把它迁移到这条新连接上,并把当前状态补发回去 —— + // 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在 + // idle;不迁移 socket 归属的话,旧连接一断,handleHelpCancel 的 + // `request.socket === ws` 校验就会认不出这条新连接发起的取消 + const request = getRequest(ws.data.userId) + if (!request) return + request.socket = ws + + const room = getRoom(ws.data.userId) + if (room && room.studentSocket !== ws) { + // 旧 socket 不再是这间房的主人:清掉它的 roomOwnerId,不然它稍后触发的 + // close 会经 roomOf 认出这间刚刚转移出去的房间,把它拆了——一条早该 + // 死透的连接反而有权拆掉正在用的新连接的房间 + room.studentSocket.data.roomOwnerId = undefined + room.studentSocket = ws + ws.data.roomOwnerId = ws.data.userId + } + + if (request.status === "pending") { + sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) }) + } else if (request.status === "active") { + sendHelpStatus(ws, "active", { teacherName: request.teacherName ?? "" }) } } diff --git a/apps/web/src/shared/composables/websocket.ts b/apps/web/src/shared/composables/websocket.ts index 9a885d2..15cd77d 100644 --- a/apps/web/src/shared/composables/websocket.ts +++ b/apps/web/src/shared/composables/websocket.ts @@ -736,6 +736,7 @@ export interface CollabMessage extends WebSocketMessage { */ export class CollabWebSocket extends BaseWebSocket { private binaryHandler: ((data: ArrayBuffer) => void) | null = null + private connectHandler: (() => void) | null = null constructor() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" @@ -746,7 +747,21 @@ export class CollabWebSocket extends BaseWebSocket { this.binaryHandler = handler } + /** + * 每次连接**建立**都触发,含重连 —— 不止首次 connect()。用来在重连瞬间 + * 清掉本地缓存的求助/房间状态:旧连接期间的 pending/active 可能早就过时了, + * 服务端会在 handleCollabOpen 里紧接着补发 requests(老师)或 help_status + * (还在排队/协作中的学生),补发落地前先归零,好过让过时状态活过一次重连。 + */ + setConnectHandler(handler: (() => void) | null) { + this.connectHandler = handler + } + protected override onBinary(data: ArrayBuffer) { this.binaryHandler?.(data) } + + protected override onConnected() { + this.connectHandler?.() + } } diff --git a/apps/web/src/shared/store/collab.ts b/apps/web/src/shared/store/collab.ts index 693ea11..c1d8b7b 100644 --- a/apps/web/src/shared/store/collab.ts +++ b/apps/web/src/shared/store/collab.ts @@ -88,9 +88,15 @@ export const useCollabStore = defineStore("collab", () => { } return case "room_closed": + // 不管 reason 一律先归位到 idle:学生这一侧的 socket 发送失败时, + // 服务端把它从请求表里摘掉却**发不出**任何纠正性的 help_status + // (那正是刚失败的那条 socket),不这样兜底 store 会卡在陈旧的 + // active/pending 上再也回不来。老师掉线的情况服务端会紧接着另发一条 + // help_status:pending——teardownRoom 里 room_closed 先发、 + // requeueAfterTeacherGone 后发,同一条连接上消息严格按发送顺序到达, + // 这里先归零,那条 pending 补发会立刻把它纠正回来,不会被这次重置盖掉 room.value = null - // 老师掉线时服务端会另发一条 help_status:pending,这里不抢着改学生状态 - if (data.reason === "done") helpStatus.value = "idle" + helpStatus.value = "idle" notice.value = data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束" return @@ -102,6 +108,19 @@ export const useCollabStore = defineStore("collab", () => { ws.addHandler(handleMessage) + // 每次连接**建立**都清一遍本地状态,不止首次 connect() —— 重连(掉线重连、 + // API 重启后的自动重连)同样会触发。旧连接期间的 pending/active/requests + // 可能早就过时了:老师端等服务端在 handleCollabOpen 里重新推 requests 补齐; + // 学生端等服务端补发的 help_status 补齐(真在排队/协作中会被立刻纠正回来), + // 不该让上一条连接的陈旧状态越过重连活下来 + ws.setConnectHandler(() => { + requests.value = [] + helpStatus.value = "idle" + queueAhead.value = 0 + teacherName.value = "" + room.value = null + }) + function connect() { ws.connect() } @@ -110,7 +129,10 @@ export const useCollabStore = defineStore("collab", () => { ws.disconnect() requests.value = [] helpStatus.value = "idle" + queueAhead.value = 0 + teacherName.value = "" room.value = null + notice.value = "" } function requestHelp(problemId: string) {