import { and, desc, eq, isNull, sql } from "drizzle-orm" import { touchSession } from "../auth/session" import { db, schema } from "../db" import { toAdminType, type StatisticInfo } from "@oj2/contract" import { TEACHER_ROLES } from "../routes/helpers" import { addRequest, addTeacher, clearPresence, closeRoom, getRequest, getRoom, hasTeacherOnline, listRequests, normalizeCollabLanguage, openRoom, presenceOf, queueAheadOf, removeRequest, removeTeacher, roomOf, setPresence, teacherSockets, type CollabLatest, type CollabSocket, type HelpRequest, type Room, } from "./state" /** * `type: "error"` 的 message **会被前端原样弹成 toast**(store 的 case "error" * → setNotice → CollabHost 的 message.info),所以这里一律写中文、写成学生看得懂的 * 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是 * 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。 */ function isTeacher(ws: CollabSocket) { return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? "")) } /** 推给老师的列表条目。不含 socket,也不含任何代码内容 */ function serializeRequest(request: HelpRequest) { return { studentId: request.studentId, studentName: request.studentName, className: request.className, problemId: request.problemId, problemTitle: request.problemTitle, latest: request.latest, createdAt: request.createdAt, status: request.status, teacherName: request.teacherName ?? null, } } /** * 把最新的排队位置推给每个还在等的学生。 * * queueAhead 原来只在「建请求 / 重连 / 退回排队」这三处推过,前面的人被接走或被 * 取消之后不重算 —— 五个人排队、前四个都处理完了,第五个还一直显示「前面还有 4 人」。 */ function broadcastQueuePositions() { for (const request of listRequests()) { if (request.status !== "pending") continue sendHelpStatus(request.socket, "pending", { queueAhead: queueAheadOf(request.studentId), }) } } /** * 队列变了:老师端收全量列表,排队中的学生各自收自己的新位置。 * * 两件事捏在一起是因为它们永远同时发生 —— 拆成两个函数分别调,迟早会在某条 * 路径上漏掉一个。 */ export function broadcastRequests() { const payload = JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest), }) for (const ws of teacherSockets()) ws.send(payload) broadcastQueuePositions() } function sendHelpStatus( ws: CollabSocket, status: "pending" | "active" | "cancelled" | "no_teacher" | "dismissed", extra: Record = {}, ) { 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) { // 协作中的学生换了一条连接。**不迁移房间,直接拆掉。** // // 原来这里是把 studentSocket 换成新连接就算完,转发确实转到新连接了, // 但客户端接不住:前端每次连接建立都会把 room 清成 null(旧连接的状态 // 不该越过重连活下来),而这里只补发了 help_status,没补 room_open —— // 于是学生页面显示「老师正在帮你」、编辑器却早就把 yCollab 摘了, // 老师照常敲字、一个字也到不了对面。正是 handleCollabBinary 注释里说的 // 「看起来在协作、其实各看各的」,比老实断开更糟。 // // 而补发 room_open 也修不好:Yjs 的文档状态跟着旧连接一起没了,新连接 // 只能新建 Y.Doc,再拿学生编辑器里的内容当种子插进去,就会和老师那份 // 已有内容合并成重复文本(两份 doc 的 item 身份不同,CRDT 不去重)。 // 续接一个 CRDT 会话不是哑转发层做得到的事。 // // 所以退回排队,老师再点一次 —— 和老师掉线走的是同一条路子。学生的代码 // 一直在他自己的编辑器里,不受影响。 closeRoom(room.studentId) room.studentSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined room.teacherSocket.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" })) request.status = "pending" request.teacherId = undefined request.teacherName = undefined broadcastRequests() } 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) clearPresence(ws.data.userId, 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 language?: unknown reason?: unknown timestamp?: unknown } try { message = JSON.parse(raw) as typeof message } catch { ws.send( JSON.stringify({ type: "error", message: "消息格式不对,请刷新页面重试", }), ) return } // 心跳不查库,和 /ws/submissions 的处理一致 if (message.type === "ping") { ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp })) return } // 握手时校验过一次不算数 —— 这条连接能挂几个小时 if (!(await touchSession(ws.data.token, ws.data.userId))) { ws.close(1008, "Session expired") return } switch (message.type) { case "help_request": await handleHelpRequest(ws, message.problemId, message.language) return case "help_language": handleHelpLanguage(ws, message.language) return case "help_cancel": handleHelpCancel(ws) return case "accept": await handleAccept(ws, message.studentId) return case "reject": await handleReject(ws, message.studentId) return case "presence": handlePresence(ws, message.problemId, message.language) return case "peek": await handlePeek(ws, message.studentId) return case "dismiss": await handleDismiss(ws, message.problemId) return case "leave": handleLeave(ws, message.reason) return default: ws.send( JSON.stringify({ type: "error", message: "不认识的操作,请刷新页面重试", }), ) } } async function handleHelpRequest(ws: CollabSocket, problemId: unknown, language: unknown) { if (typeof problemId !== "string" || !problemId) { ws.send(JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" })) 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({ id: schema.problem.id, 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], latest] = await Promise.all([ db .select({ className: schema.user.className }) .from(schema.user) .where(eq(schema.user.id, ws.data.userId)) .limit(1), latestSubmission(ws.data.userId, problem.id), ]) const request: HelpRequest = { studentId: ws.data.userId, studentName: ws.data.username ?? "", className: student?.className ?? null, problemId, problemPk: problem.id, problemTitle: problem.title, latest, language: normalizeCollabLanguage(language), createdAt: Date.now(), status: "pending", socket: ws, } addRequest(request) // 老师正主动看着他的代码时他举了手:不用再排队,就当这位老师接了 const room = getRoom(ws.data.userId) if (room?.mode === "peek" && room.studentSocket === ws && room.problemId === problemId) { room.mode = "help" request.status = "active" request.teacherId = room.teacherId request.teacherName = room.teacherSocket.data.username ?? "" sendHelpStatus(ws, "active", { teacherName: request.teacherName }) broadcastRequests() return } sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) }) broadcastRequests() } /** * 学生在求助期间换了语言。 * * 只更新语言,不动队列位置、不动房间 —— 换语言不该让他重新排队。已经在协作中的 * 话再推一条 room_language,老师端的高亮和补全立刻跟着换;不推的话老师会拿着 * 建房那一刻的语言,对着一套错的补全替学生写代码。 */ function handleHelpLanguage(ws: CollabSocket, language: unknown) { const request = getRequest(ws.data.userId) // 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助 if (!request || request.socket !== ws) return const next = normalizeCollabLanguage(language) if (request.language === next) return request.language = next const room = getRoom(ws.data.userId) if (!room) return room.language = next room.teacherSocket.send(JSON.stringify({ type: "room_language", language: next })) } 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: "学生标识不对,请刷新页面重试", }), ) return } if (!(await stillTeacher(ws))) 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 } acceptRequest(ws, request) } /** 接单:建房、两边发 room_open。调用方已经核过老师身份、老师不在别的房间、这条还在排队 */ function acceptRequest(ws: CollabSocket, request: HelpRequest) { const studentId = request.studentId request.status = "active" request.teacherId = ws.data.userId request.teacherName = ws.data.username ?? "" ws.data.roomOwnerId = studentId request.socket.data.roomOwnerId = studentId const room: Room = { studentId, mode: "help", teacherId: ws.data.userId, studentSocket: request.socket, teacherSocket: ws, problemId: request.problemId, language: request.language, } openRoom(room) sendRoomOpen(room, request.studentName, request.teacherName, request.className) sendHelpStatus(request.socket, "active", { teacherName: request.teacherName }) broadcastRequests() } /** * 两边的 room_open。教师端多带学生的 id 和班级:协作条要知道他举手多久了(按 id 在列表里找), * 「帮完了」要回到哪个班的看板 */ function sendRoomOpen( room: Room, studentName: string, teacherName: string, className: string | null, ) { const frame = (peer: Record) => JSON.stringify({ type: "room_open", mode: room.mode, peer, problemId: room.problemId, language: room.language, }) room.studentSocket.send(frame({ name: teacherName, role: "teacher" })) room.teacherSocket.send( frame({ name: studentName, role: "student", id: room.studentId, className }), ) } async function handleReject(ws: CollabSocket, studentId: unknown) { if (!isTeacher(ws) || typeof studentId !== "number") return // reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级 // 或禁用"的老师继续掐掉排队中的求助 if (!(await stillTeacher(ws))) return const request = getRequest(studentId) // 已经在协作中的不能靠 reject 掐掉,那是 leave 的事 if (!request || request.status === "active") return removeRequest(studentId) sendHelpStatus(request.socket, "cancelled") broadcastRequests() } /** * 主动退出房间。**两种语义,靠 reason 分**: * * - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束, * 求助记录一并清掉; * - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在 * 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回 * 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。 * * 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」, * 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。 */ function handleLeave(ws: CollabSocket, reason: unknown) { const room = roomOf(ws) if (!room) return if (reason !== "left") { teardownRoom(room, "done") return } const side = ws === room.teacherSocket ? "teacher" : "student" teardownRoom(room, "peer_left", side, ws) } /** * 拆房间。reason 决定两端看到什么: * done —— 有人主动结束,双方都收到,请求一并清除 * peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose / * handleCollabBinary * peer_left —— 有人离开了这道题的页面(handleLeave 的 "left") * * offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 → * 随人清掉。不传时只拆房间。 * * initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」 * 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句 * 「对方离开了」,也不该看到「老师已结束这次帮忙」。 */ function teardownRoom( room: Room, reason: "done" | "peer_offline" | "peer_left", offlineSide?: "student" | "teacher", initiator?: CollabSocket, ) { closeRoom(room.studentId) room.studentSocket.data.roomOwnerId = undefined room.teacherSocket.data.roomOwnerId = undefined // 发起方收到的是「我自己干的」那一版:点了结束就是 done,离开页面是 self_left。 // 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话 const selfReason = reason === "peer_left" ? "self_left" : "done" for (const socket of [room.studentSocket, room.teacherSocket]) { socket.send( JSON.stringify({ type: "room_closed", reason: socket === initiator ? selfReason : reason, }), ) } 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) } } /** * 握手时的 adminType 是那一刻的快照,动手之前按库里的真实身份复核一次。 * 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义。 * 不是老师了就断开连接,返回 false */ async function stillTeacher(ws: CollabSocket) { if (!isTeacher(ws)) { ws.send(JSON.stringify({ type: "error", message: "无权限" })) return false } 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(toAdminType(teacher.adminType))) { ws.close(1008, "Permission revoked") return false } return true } /** 这个学生这道题(非比赛)最后一次代码提交,举手面板按它写错法 */ async function latestSubmission(userId: number, problemPk: number): Promise { const [row] = await db .select({ id: schema.submission.id, result: schema.submission.result, runtimeError: sql< StatisticInfo["runtime_error"] | null >`${schema.submission.statisticInfo}->'runtime_error'`, sampleCheck: sql< StatisticInfo["sample_check"] | null >`${schema.submission.statisticInfo}->'sample_check'`, }) .from(schema.submission) .where( and( eq(schema.submission.userId, userId), eq(schema.submission.problemId, problemPk), isNull(schema.submission.contestId), ), ) .orderBy(desc(schema.submission.createTime)) .limit(1) if (!row) return null return { submissionId: row.id, result: row.result, runtimeError: row.runtimeError ?? undefined, sampleCheck: row.sampleCheck ?? undefined, } } /** * 判完了一条提交:这个人要是举着手,刷新他那条的错法 —— 举手之后又交了一次很常见, * 不刷新的话面板上一直写着举手那一刻的错。由 websocket.ts 的判题事件桥调 */ export async function refreshRequestLatest(userId: number) { const request = getRequest(userId) if (!request) return const latest = await latestSubmission(userId, request.problemPk) // 查库期间这条可能已经被放下、或者换成了另一道题的新求助 if (getRequest(userId) !== request) return request.latest = latest broadcastRequests() } /** * 学生报告他此刻开着哪道题的编辑器(null = 离开了题目页 / 编辑器卸载了)。 * 只认学生:老师的编辑器不需要被别人打开。协作中换语言也走这里同步给老师 */ function handlePresence(ws: CollabSocket, problemId: unknown, language: unknown) { if (isTeacher(ws)) return if (typeof problemId !== "string" || !problemId) { clearPresence(ws.data.userId, ws) return } const lang = normalizeCollabLanguage(language) setPresence(ws.data.userId, { problemId, language: lang, socket: ws }) const room = getRoom(ws.data.userId) if (room && room.studentSocket === ws && room.language !== lang) { room.language = lang room.teacherSocket.send(JSON.stringify({ type: "room_language", language: lang })) } } /** * 老师在看板上主动打开一个学生正在写的代码(学生没举手)。 * * 用户定的:学生**不能拒绝**,但必须看得到 —— 他那边按钮变成「xuyue 在看你的代码」; * 老师可以直接改。他正举着手的话就是接单,和「帮他」一样。 */ async function handlePeek(ws: CollabSocket, studentId: unknown) { if (typeof studentId !== "number") { ws.send(JSON.stringify({ type: "error", message: "学生标识不对,请刷新页面重试" })) return } if (!(await stillTeacher(ws))) return if (!teacherSockets().has(ws)) return if (roomOf(ws)) { ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" })) return } const busy = getRoom(studentId) if (busy) { const name = busy.teacherSocket.data.username ?? "别的老师" ws.send(JSON.stringify({ type: "error", message: `${name} 正在看他的代码` })) return } const request = getRequest(studentId) if (request?.status === "pending") { acceptRequest(ws, request) return } const presence = presenceOf(studentId) if (!presence) { ws.send(JSON.stringify({ type: "error", message: "他现在没开着题目,打不开" })) return } const [student] = await db .select({ username: schema.user.username, className: schema.user.className }) .from(schema.user) .where(eq(schema.user.id, studentId)) .limit(1) // 查库是个 await 点:期间老师可能断了、学生可能走了、别的老师可能先打开了 if (!student || !teacherSockets().has(ws) || roomOf(ws) || getRoom(studentId)) return if (presenceOf(studentId) !== presence) return ws.data.roomOwnerId = studentId presence.socket.data.roomOwnerId = studentId const room: Room = { studentId, mode: "peek", teacherId: ws.data.userId, studentSocket: presence.socket, teacherSocket: ws, problemId: presence.problemId, language: presence.language, } openRoom(room) sendRoomOpen(room, student.username, ws.data.username ?? "", student.className) } /** * 「讲完了,都放下」:同一道题好几个人举手,老师停下来全班讲完了,把这道题还在排队的手 * 一次放下。正在帮的那个不动。学生收到「xuyue 讲了 2020,还不会就再举手」 */ async function handleDismiss(ws: CollabSocket, problemId: unknown) { if (typeof problemId !== "string" || !problemId) return if (!(await stillTeacher(ws))) return const teacherName = ws.data.username ?? "" let changed = false for (const request of listRequests()) { if (request.problemId !== problemId || request.status !== "pending") continue removeRequest(request.studentId) sendHelpStatus(request.socket, "dismissed", { teacherName, problemId }) changed = true } if (changed) broadcastRequests() }