学生发起求助,状态与排队位置显示在按钮旁。 教师端入口不在题目页,所以对教师隐藏这个按钮。 顺带修正 handleHelpCancel:去掉 request.socket !== ws 的校验。 学生取消自己的求助,不管从他哪个标签页发起都合法—— getRequest(ws.data.userId) 已经把范围锁在这一个用户上了, 不是跨用户操作,不需要再比对是不是同一条连接。这层校验此前 会让「第二个标签页点取消」被服务端静默丢弃,前端却已经乐观 地把按钮变回「求助」,是一处 UI 说谎;现在两边状态一致。 handleCollabClose 里排队分支的 socket 归属校验不受影响, 那里的关闭事件是顺带触发的,仍然需要认出是不是本人这条连接。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { and, eq, isNull } from "drizzle-orm"
|
||
|
||
import { touchSession } from "../auth/session"
|
||
import { db, schema } from "../db"
|
||
import { TEACHER_ROLES } from "../routes/helpers"
|
||
import {
|
||
addRequest,
|
||
addTeacher,
|
||
closeRoom,
|
||
getRequest,
|
||
getRoom,
|
||
hasTeacherOnline,
|
||
listRequests,
|
||
openRoom,
|
||
queueAheadOf,
|
||
removeRequest,
|
||
removeTeacher,
|
||
roomOf,
|
||
teacherSockets,
|
||
type CollabSocket,
|
||
type HelpRequest,
|
||
type Room,
|
||
} from "./state"
|
||
|
||
function isTeacher(ws: CollabSocket) {
|
||
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
|
||
}
|
||
|
||
/** 推给老师的列表条目。不含 socket,也不含任何代码内容 */
|
||
function serializeRequest(request: HelpRequest) {
|
||
return {
|
||
studentId: request.studentId,
|
||
studentName: request.studentName,
|
||
className: request.className,
|
||
problemId: request.problemId,
|
||
problemTitle: request.problemTitle,
|
||
createdAt: request.createdAt,
|
||
status: request.status,
|
||
teacherName: request.teacherName ?? null,
|
||
}
|
||
}
|
||
|
||
export function broadcastRequests() {
|
||
const payload = JSON.stringify({
|
||
type: "requests",
|
||
list: listRequests().map(serializeRequest),
|
||
})
|
||
for (const ws of teacherSockets()) ws.send(payload)
|
||
}
|
||
|
||
function sendHelpStatus(
|
||
ws: CollabSocket,
|
||
status: "pending" | "active" | "cancelled" | "no_teacher",
|
||
extra: Record<string, unknown> = {},
|
||
) {
|
||
ws.send(JSON.stringify({ type: "help_status", status, ...extra }))
|
||
}
|
||
|
||
export function handleCollabOpen(ws: CollabSocket) {
|
||
if (isTeacher(ws)) {
|
||
addTeacher(ws)
|
||
// 新上线的老师要立刻看到当前队列,不能等下一次变更
|
||
ws.send(
|
||
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
||
)
|
||
return
|
||
}
|
||
|
||
// 学生(重)连:如果这个账号名下已经有一条请求(掉线重连回来,或者干脆是
|
||
// 同账号第二个标签页),把它迁移到这条新连接上,并把当前状态补发回去 ——
|
||
// 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在
|
||
// idle;不迁移 socket 归属的话,sendHelpStatus/accept 等后续推送会发到一条
|
||
// 已经不用的旧连接上,新连接(新标签页)什么都收不到
|
||
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 ?? "" })
|
||
}
|
||
}
|
||
|
||
/** 老师从房间消失(掉线,或发送失败被判定为事实上不可达):请求退回排队,
|
||
* 学生不必重新点 —— 可能只是网络抖了一下 */
|
||
function requeueAfterTeacherGone(studentId: number) {
|
||
const request = getRequest(studentId)
|
||
if (request) {
|
||
request.status = "pending"
|
||
request.teacherId = undefined
|
||
request.teacherName = undefined
|
||
sendHelpStatus(request.socket, "pending", {
|
||
queueAhead: queueAheadOf(studentId),
|
||
})
|
||
}
|
||
}
|
||
|
||
export function handleCollabClose(ws: CollabSocket) {
|
||
if (isTeacher(ws)) removeTeacher(ws)
|
||
|
||
const room = roomOf(ws)
|
||
if (room) {
|
||
closeRoom(room.studentId)
|
||
room.studentSocket.data.roomOwnerId = undefined
|
||
room.teacherSocket.data.roomOwnerId = undefined
|
||
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
|
||
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
|
||
|
||
if (ws === room.teacherSocket) {
|
||
requeueAfterTeacherGone(room.studentId)
|
||
} else {
|
||
// 学生掉线:请求随人走
|
||
removeRequest(room.studentId)
|
||
}
|
||
} else if (!isTeacher(ws)) {
|
||
// 还在排队时关掉页面,请求也该消失 —— 但只能收自己这条。同一账号可能开了两个
|
||
// 标签页,另一个标签页可能已经把请求接成 active(甚至已经换了一拨新请求),
|
||
// 不加 socket 归属和状态检查,这里会把活跃房间的请求记录连根拔起
|
||
const request = getRequest(ws.data.userId)
|
||
if (request && request.socket === ws && request.status !== "active") {
|
||
removeRequest(ws.data.userId)
|
||
}
|
||
}
|
||
|
||
broadcastRequests()
|
||
}
|
||
|
||
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||
let message: { type?: unknown; problemId?: unknown; studentId?: unknown }
|
||
try {
|
||
message = JSON.parse(raw) as typeof message
|
||
} catch {
|
||
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
|
||
return
|
||
}
|
||
|
||
// 心跳不查库,和 /ws/submissions 的处理一致
|
||
if (message.type === "ping") {
|
||
ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }))
|
||
return
|
||
}
|
||
|
||
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
|
||
if (!(await touchSession(ws.data.token))) {
|
||
ws.close(1008, "Session expired")
|
||
return
|
||
}
|
||
|
||
switch (message.type) {
|
||
case "help_request":
|
||
await handleHelpRequest(ws, message.problemId)
|
||
return
|
||
case "help_cancel":
|
||
handleHelpCancel(ws)
|
||
return
|
||
case "accept":
|
||
await handleAccept(ws, message.studentId)
|
||
return
|
||
case "reject":
|
||
await handleReject(ws, message.studentId)
|
||
return
|
||
case "leave":
|
||
handleLeave(ws)
|
||
return
|
||
default:
|
||
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
|
||
}
|
||
}
|
||
|
||
async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
|
||
if (typeof problemId !== "string" || !problemId) {
|
||
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" }))
|
||
return
|
||
}
|
||
if (isTeacher(ws)) {
|
||
ws.send(JSON.stringify({ type: "error", message: "教师不能发起求助" }))
|
||
return
|
||
}
|
||
if (!hasTeacherOnline()) {
|
||
sendHelpStatus(ws, "no_teacher")
|
||
return
|
||
}
|
||
|
||
// 只认非比赛题:contest_id 为空的那条。比赛题不提供求助
|
||
const [problem] = await db
|
||
.select({ title: schema.problem.title })
|
||
.from(schema.problem)
|
||
.where(
|
||
and(
|
||
eq(schema.problem.displayId, problemId),
|
||
isNull(schema.problem.contestId),
|
||
),
|
||
)
|
||
.limit(1)
|
||
if (!problem) {
|
||
ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }))
|
||
return
|
||
}
|
||
|
||
const existing = getRequest(ws.data.userId)
|
||
// 已经在协作中就不重复登记,否则会把正在进行的房间挤掉
|
||
if (existing?.status === "active") return
|
||
|
||
const [student] = await db
|
||
.select({ className: schema.user.className })
|
||
.from(schema.user)
|
||
.where(eq(schema.user.id, ws.data.userId))
|
||
.limit(1)
|
||
|
||
addRequest({
|
||
studentId: ws.data.userId,
|
||
studentName: ws.data.username ?? "",
|
||
className: student?.className ?? null,
|
||
problemId,
|
||
problemTitle: problem.title,
|
||
createdAt: Date.now(),
|
||
status: "pending",
|
||
socket: ws,
|
||
})
|
||
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
|
||
broadcastRequests()
|
||
}
|
||
|
||
function handleHelpCancel(ws: CollabSocket) {
|
||
const request = getRequest(ws.data.userId)
|
||
// 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都
|
||
// 合法——getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,不是跨用户
|
||
// 操作。这里和 handleCollabClose 的排队分支不是同一类问题:那边关闭事件是
|
||
// 「顺带」触发的,必须认出是不是本人这条连接;这里是用户主动点了取消
|
||
if (!request || request.status === "active") return
|
||
removeRequest(ws.data.userId)
|
||
broadcastRequests()
|
||
}
|
||
|
||
async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||
if (!isTeacher(ws)) {
|
||
ws.send(JSON.stringify({ type: "error", message: "无权限" }))
|
||
return
|
||
}
|
||
if (typeof studentId !== "number") {
|
||
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" }))
|
||
return
|
||
}
|
||
|
||
// 握手时的 adminType 是那一刻的快照,接单前按库里的真实身份复核一次。
|
||
// 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义
|
||
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
|
||
}
|
||
|
||
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
|
||
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
|
||
// 还作不作数。continuation 里不能再对着一个死 socket 建房间
|
||
if (!teacherSockets().has(ws)) return
|
||
|
||
// 老师同时只能在一个房间
|
||
if (roomOf(ws)) {
|
||
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
|
||
return
|
||
}
|
||
|
||
const request = getRequest(studentId)
|
||
if (!request || request.status === "active" || getRoom(studentId)) {
|
||
// 被别人接走了、学生已经撤销,或者这个学生 id 名下已经有一个房间在挂着
|
||
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
|
||
// 回一份最新列表让老师端自己纠正
|
||
ws.send(
|
||
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
|
||
)
|
||
return
|
||
}
|
||
|
||
request.status = "active"
|
||
request.teacherId = ws.data.userId
|
||
request.teacherName = ws.data.username ?? ""
|
||
|
||
ws.data.roomOwnerId = studentId
|
||
request.socket.data.roomOwnerId = studentId
|
||
openRoom({
|
||
studentId,
|
||
teacherId: ws.data.userId,
|
||
studentSocket: request.socket,
|
||
teacherSocket: ws,
|
||
problemId: request.problemId,
|
||
})
|
||
|
||
const openFrame = (peerName: string, peerRole: "student" | "teacher") =>
|
||
JSON.stringify({
|
||
type: "room_open",
|
||
peer: { name: peerName, role: peerRole },
|
||
problemId: request.problemId,
|
||
})
|
||
request.socket.send(openFrame(request.teacherName, "teacher"))
|
||
ws.send(openFrame(request.studentName, "student"))
|
||
sendHelpStatus(request.socket, "active", { teacherName: request.teacherName })
|
||
broadcastRequests()
|
||
}
|
||
|
||
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
|
||
removeRequest(studentId)
|
||
sendHelpStatus(request.socket, "cancelled")
|
||
broadcastRequests()
|
||
}
|
||
|
||
/** 主动退出房间。老师点关闭、学生点结束都走这里 */
|
||
function handleLeave(ws: CollabSocket) {
|
||
const room = roomOf(ws)
|
||
if (!room) return
|
||
teardownRoom(room, "done")
|
||
}
|
||
|
||
/**
|
||
* 拆房间。reason 决定两端看到什么:
|
||
* done —— 有人主动结束,双方都收到,请求一并清除
|
||
* peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
|
||
* handleCollabBinary。offlineSide 是消失的那一方:老师消失,
|
||
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有
|
||
* handleLeave 走 "done")不做这一步,只拆房间
|
||
*/
|
||
function teardownRoom(
|
||
room: Room,
|
||
reason: "done" | "peer_offline",
|
||
offlineSide?: "student" | "teacher",
|
||
) {
|
||
closeRoom(room.studentId)
|
||
room.studentSocket.data.roomOwnerId = undefined
|
||
room.teacherSocket.data.roomOwnerId = undefined
|
||
const frame = JSON.stringify({ type: "room_closed", reason })
|
||
room.studentSocket.send(frame)
|
||
room.teacherSocket.send(frame)
|
||
if (reason === "done") {
|
||
removeRequest(room.studentId)
|
||
} else if (offlineSide === "teacher") {
|
||
requeueAfterTeacherGone(room.studentId)
|
||
} else if (offlineSide === "student") {
|
||
removeRequest(room.studentId)
|
||
}
|
||
broadcastRequests()
|
||
}
|
||
|
||
/**
|
||
* Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。
|
||
*
|
||
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
|
||
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
|
||
*/
|
||
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
|
||
// 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值,
|
||
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
|
||
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
|
||
if (data.length === 0) return
|
||
|
||
const room = roomOf(ws)
|
||
if (!room) return
|
||
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
|
||
const sent = peer.send(data)
|
||
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
|
||
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
|
||
// 当成失败,慢网/大粘贴一触发背压就把正常房间拆掉,是本该保护的场景反而先死
|
||
if (sent === 0) {
|
||
// 真丢帧:两边的 Yjs 文档会从此悄悄分叉——教学工具里"看起来在协作、其实
|
||
// 各看各的代码"比老实断开更糟,不做续传,直接拆房间。和教师断线走同一条
|
||
// 收尾路径:老师那侧消失就把请求退回排队,不让学生卡死在 active 出不来
|
||
console.error("Collab binary forward failed, tearing down room", {
|
||
studentId: room.studentId,
|
||
})
|
||
const offlineSide = peer === room.teacherSocket ? "teacher" : "student"
|
||
teardownRoom(room, "peer_offline", offlineSide)
|
||
}
|
||
}
|