fix(api): collab 房间越权与几处时序/静默丢帧缺口

Critical:学生排队断线分支补归属+状态校验,避免同账号双标签页
把 active 请求错删、被另一个学生请求顶替后又被别的老师抢建出
第二间房;handleAccept 补 getRoom(studentId) 兜底同一学生 id
下已有房间的情况。

Important:handleAccept 的库查询是个 await 点,之后补上
teacherSockets().has(ws) 判活,教师在等待期间断线不会再对着
死 socket 建房间。

Minor:handleReject 补上和 handleAccept 一致的库复核,堵住
连接存活期间被降级/禁用的教师继续掐请求的口子;
handleCollabBinary 检查 peer.send() 返回值,转发失败时走既有
teardownRoom 拆房间,不再静默丢帧、悄悄分叉两边的代码。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-08-28 02:48:35 -06:00
parent b14639d890
commit 457df1ef3c

View File

@@ -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")
}
}