feat(api): collab 房间管理与 Yjs 帧转发

accept 时按库里的 adminType 复核身份,房间以学生为键。
服务端只按房间转发二进制帧,不解析内容。
老师掉线请求退回排队,学生掉线请求随人清除。

顺带处理三项 Task 2 评审遗留:
- TEACHER_ROLES 去重,handler.ts 改为从 routes/helpers.ts 导入,不再自留一份
- 补全学生排队中断线(尚未进房间)的清理分支,避免陈旧请求卡死在列表里
- 修正 websocket.ts 里一处过期注释:username/adminType 是三种 kind 都会填,
  不是只有 collab 才填

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-08-28 02:36:12 -06:00
parent 1db2c49b87
commit b14639d890
4 changed files with 184 additions and 7 deletions

View File

@@ -2,22 +2,25 @@ 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,
hasTeacherOnline,
listRequests,
openRoom,
queueAheadOf,
removeRequest,
removeTeacher,
roomOf,
teacherSockets,
type CollabSocket,
type HelpRequest,
type Room,
} from "./state"
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
}
@@ -64,7 +67,36 @@ export function handleCollabOpen(ws: CollabSocket) {
export function handleCollabClose(ws: CollabSocket) {
if (isTeacher(ws)) removeTeacher(ws)
// 房间与请求的清理在 Task 3 补
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) {
// 老师掉线:请求退回排队,学生不必重新点 —— 可能只是网络抖了一下
const request = getRequest(room.studentId)
if (request) {
request.status = "pending"
request.teacherId = undefined
request.teacherName = undefined
sendHelpStatus(request.socket, "pending", {
queueAhead: queueAheadOf(room.studentId),
})
}
} else {
// 学生掉线:请求随人走
removeRequest(room.studentId)
}
} else if (!isTeacher(ws)) {
// 还在排队时关掉页面,请求也该消失
removeRequest(ws.data.userId)
}
broadcastRequests()
}
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
@@ -95,6 +127,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
case "help_cancel":
handleHelpCancel(ws)
return
case "accept":
await handleAccept(ws, message.studentId)
return
case "reject":
handleReject(ws, message.studentId)
return
case "leave":
handleLeave(ws)
return
default:
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
}
@@ -161,5 +202,111 @@ function handleHelpCancel(ws: CollabSocket) {
broadcastRequests()
}
/** Task 3 会把它换成真正的按房间转发。此刻房间还不存在,先收下不处理 */
export function handleCollabBinary(_ws: CollabSocket, _data: Buffer | Uint8Array) {}
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
}
// 老师同时只能在一个房间
if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
return
}
const request = getRequest(studentId)
if (!request || request.status === "active") {
// 被别人接走了或者学生已经撤销 —— 回一份最新列表让老师端自己纠正
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()
}
function handleReject(ws: CollabSocket, studentId: unknown) {
if (!isTeacher(ws) || typeof studentId !== "number") 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
*/
function teardownRoom(room: Room, reason: "done" | "peer_offline") {
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)
broadcastRequests()
}
/**
* Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。
*
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
const room = roomOf(ws)
if (!room) return
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
peer.send(data)
}