学生编辑器报「在写哪道题」,看板接口带 editing;新增 peek / dismiss 两条协作消息, 求助记录带这道题最后一次提交(判完新的一次会刷新)。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
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()
|
||
}
|