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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<number, Room>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user