From b14639d89060ed215d1ae0495dd6317a6bdf2862 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 28 Aug 2026 02:36:12 -0600 Subject: [PATCH] =?UTF-8?q?feat(api):=20collab=20=E6=88=BF=E9=97=B4?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=20Yjs=20=E5=B8=A7=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accept 时按库里的 adminType 复核身份,房间以学生为键。 服务端只按房间转发二进制帧,不解析内容。 老师掉线请求退回排队,学生掉线请求随人清除。 顺带处理三项 Task 2 评审遗留: - TEACHER_ROLES 去重,handler.ts 改为从 routes/helpers.ts 导入,不再自留一份 - 补全学生排队中断线(尚未进房间)的清理分支,避免陈旧请求卡死在列表里 - 修正 websocket.ts 里一处过期注释:username/adminType 是三种 kind 都会填, 不是只有 collab 才填 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- apps/api/src/collab/handler.ts | 157 +++++++++++++++++++++++++++++++-- apps/api/src/collab/state.ts | 30 +++++++ apps/api/src/routes/helpers.ts | 2 +- apps/api/src/websocket.ts | 2 +- 4 files changed, 184 insertions(+), 7 deletions(-) diff --git a/apps/api/src/collab/handler.ts b/apps/api/src/collab/handler.ts index 239da17..a397ee1 100644 --- a/apps/api/src/collab/handler.ts +++ b/apps/api/src/collab/handler.ts @@ -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) +} diff --git a/apps/api/src/collab/state.ts b/apps/api/src/collab/state.ts index 3493be6..6418147 100644 --- a/apps/api/src/collab/state.ts +++ b/apps/api/src/collab/state.ts @@ -76,8 +76,38 @@ export function teacherSockets() { return teachers } +export interface Room { + /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ + studentId: number + teacherId: number + studentSocket: CollabSocket + teacherSocket: CollabSocket + problemId: string +} + +const rooms = new Map() + +export function openRoom(room: Room) { + rooms.set(room.studentId, room) +} + +export function getRoom(studentId: number) { + return rooms.get(studentId) +} + +export function closeRoom(studentId: number) { + return rooms.delete(studentId) +} + +/** 这条连接当前所在的房间。ws.data.roomOwnerId 是房主(学生)的 id */ +export function roomOf(ws: CollabSocket) { + const ownerId = ws.data.roomOwnerId + return ownerId === undefined ? undefined : rooms.get(ownerId) +} + /** 仅供进程退出或测试用,正常路径不该调 */ export function resetCollabState() { requests.clear() teachers.clear() + rooms.clear() } diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index e1813be..ade4894 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -71,7 +71,7 @@ export function queryInteger( // 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的 //「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。 const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"] -const TEACHER_ROLES = ["Teacher Admin", "Super Admin"] +export const TEACHER_ROLES = ["Teacher Admin", "Super Admin"] // 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 —— // 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。 diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index 284a92e..048ade7 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -63,7 +63,7 @@ export interface SubmissionSocketData { token: string /** 令牌桶,open 时初始化,见 allowMessage */ rate?: { tokens: number; updatedAt: number } - /** 以下两项只有 kind === "collab" 时才填,握手时从会话里读 */ + /** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */ username?: string adminType?: string /** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */