Files
OJ2/apps/api/src/collab/handler.ts
T
xuyueandClaude Opus 5.5 573a0f967f feat(举手与协作): 老师能在看板上直接打开学生正在写的代码,同一道题好几个人举手能「讲完了,都放下」,举手面板写着每人错在哪
学生编辑器报「在写哪道题」,看板接口带 editing;新增 peek / dismiss 两条协作消息,
求助记录带这道题最后一次提交(判完新的一次会刷新)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:04:57 +08:00

738 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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) {
// 协作中的学生换了一条连接。**不迁移房间,直接拆掉。**
//
// 原来这里是把 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<string, unknown>) =>
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<CollabLatest | null> {
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()
}