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:
@@ -8,6 +8,7 @@ import {
|
|||||||
addTeacher,
|
addTeacher,
|
||||||
closeRoom,
|
closeRoom,
|
||||||
getRequest,
|
getRequest,
|
||||||
|
getRoom,
|
||||||
hasTeacherOnline,
|
hasTeacherOnline,
|
||||||
listRequests,
|
listRequests,
|
||||||
openRoom,
|
openRoom,
|
||||||
@@ -92,8 +93,13 @@ export function handleCollabClose(ws: CollabSocket) {
|
|||||||
removeRequest(room.studentId)
|
removeRequest(room.studentId)
|
||||||
}
|
}
|
||||||
} else if (!isTeacher(ws)) {
|
} 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()
|
broadcastRequests()
|
||||||
@@ -131,7 +137,7 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
|||||||
await handleAccept(ws, message.studentId)
|
await handleAccept(ws, message.studentId)
|
||||||
return
|
return
|
||||||
case "reject":
|
case "reject":
|
||||||
handleReject(ws, message.studentId)
|
await handleReject(ws, message.studentId)
|
||||||
return
|
return
|
||||||
case "leave":
|
case "leave":
|
||||||
handleLeave(ws)
|
handleLeave(ws)
|
||||||
@@ -224,6 +230,11 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
|
||||||
|
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
|
||||||
|
// 还作不作数。continuation 里不能再对着一个死 socket 建房间
|
||||||
|
if (!teacherSockets().has(ws)) return
|
||||||
|
|
||||||
// 老师同时只能在一个房间
|
// 老师同时只能在一个房间
|
||||||
if (roomOf(ws)) {
|
if (roomOf(ws)) {
|
||||||
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
|
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
|
||||||
@@ -231,8 +242,10 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const request = getRequest(studentId)
|
const request = getRequest(studentId)
|
||||||
if (!request || request.status === "active") {
|
if (!request || request.status === "active" || getRoom(studentId)) {
|
||||||
// 被别人接走了或者学生已经撤销 —— 回一份最新列表让老师端自己纠正
|
// 被别人接走了、学生已经撤销,或者这个学生 id 名下已经有一个房间在挂着
|
||||||
|
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
|
||||||
|
// 回一份最新列表让老师端自己纠正
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
||||||
)
|
)
|
||||||
@@ -265,8 +278,21 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
|||||||
broadcastRequests()
|
broadcastRequests()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReject(ws: CollabSocket, studentId: unknown) {
|
async function handleReject(ws: CollabSocket, studentId: unknown) {
|
||||||
if (!isTeacher(ws) || typeof studentId !== "number") return
|
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)
|
const request = getRequest(studentId)
|
||||||
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
|
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
|
||||||
if (!request || request.status === "active") return
|
if (!request || request.status === "active") return
|
||||||
@@ -308,5 +334,14 @@ export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array)
|
|||||||
const room = roomOf(ws)
|
const room = roomOf(ws)
|
||||||
if (!room) return
|
if (!room) return
|
||||||
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user