diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index a397ee1..47d25f9 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -8,6 +8,7 @@ import { addTeacher, closeRoom, getRequest, + getRoom, hasTeacherOnline, listRequests, openRoom, @@ -92,8 +93,13 @@ export function handleCollabClose(ws: CollabSocket) { removeRequest(room.studentId) } } else if (!isTeacher(ws)) { - // 还在排队时关掉页面,请求也该消失 - removeRequest(ws.data.userId) + // 还在排队时关掉页面,请求也该消失 —— 但只能收自己这条。同一账号可能开了两个 + // 标签页,另一个标签页可能已经把请求接成 active(甚至已经换了一拨新请求), + // 不加 socket 归属和状态检查,这里会把活跃房间的请求记录连根拔起 + const request = getRequest(ws.data.userId) + if (request && request.socket === ws && request.status !== "active") { + removeRequest(ws.data.userId) + } } broadcastRequests() @@ -131,7 +137,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) { await handleAccept(ws, message.studentId) return case "reject": - handleReject(ws, message.studentId) + await handleReject(ws, message.studentId) return case "leave": handleLeave(ws) @@ -224,6 +230,11 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { return } + // 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时 + // handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept + // 还作不作数。continuation 里不能再对着一个死 socket 建房间 + if (!teacherSockets().has(ws)) return + // 老师同时只能在一个房间 if (roomOf(ws)) { ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" })) @@ -231,8 +242,10 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { } const request = getRequest(studentId) - if (!request || request.status === "active") { - // 被别人接走了或者学生已经撤销 —— 回一份最新列表让老师端自己纠正 + if (!request || request.status === "active" || getRoom(studentId)) { + // 被别人接走了、学生已经撤销,或者这个学生 id 名下已经有一个房间在挂着 + // (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)—— + // 回一份最新列表让老师端自己纠正 ws.send( JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), ) @@ -265,8 +278,21 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) { broadcastRequests() } -function handleReject(ws: CollabSocket, studentId: unknown) { +async function handleReject(ws: CollabSocket, studentId: unknown) { if (!isTeacher(ws) || typeof studentId !== "number") return + + // reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级 + // 或禁用"的老师继续掐掉排队中的求助 + const [teacher] = await db + .select({ adminType: schema.user.adminType }) + .from(schema.user) + .where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false))) + .limit(1) + if (!teacher || !TEACHER_ROLES.includes(teacher.adminType)) { + ws.close(1008, "Permission revoked") + return + } + const request = getRequest(studentId) // 已经在协作中的不能靠 reject 掐掉,那是 leave 的事 if (!request || request.status === "active") return @@ -308,5 +334,14 @@ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) const room = roomOf(ws) if (!room) return const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket - peer.send(data) + const sent = peer.send(data) + if (sent <= 0) { + // <= 0:背压丢帧或者对端事实上已经断了。这一帧丢了,两边的 Yjs 文档从此 + // 悄悄分叉——教学工具里"看起来在协作、其实各看各的代码"比老实断开更糟, + // 不做续传,直接拆房间让双方收到 room_closed、自己决定要不要重新连 + console.error("Collab binary forward failed, tearing down room", { + studentId: room.studentId, + }) + teardownRoom(room, "peer_offline") + } }