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 { touchSession } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { TEACHER_ROLES } from "../routes/helpers"
|
||||||
import {
|
import {
|
||||||
addRequest,
|
addRequest,
|
||||||
addTeacher,
|
addTeacher,
|
||||||
|
closeRoom,
|
||||||
getRequest,
|
getRequest,
|
||||||
hasTeacherOnline,
|
hasTeacherOnline,
|
||||||
listRequests,
|
listRequests,
|
||||||
|
openRoom,
|
||||||
queueAheadOf,
|
queueAheadOf,
|
||||||
removeRequest,
|
removeRequest,
|
||||||
removeTeacher,
|
removeTeacher,
|
||||||
|
roomOf,
|
||||||
teacherSockets,
|
teacherSockets,
|
||||||
type CollabSocket,
|
type CollabSocket,
|
||||||
type HelpRequest,
|
type HelpRequest,
|
||||||
|
type Room,
|
||||||
} from "./state"
|
} from "./state"
|
||||||
|
|
||||||
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
|
|
||||||
|
|
||||||
function isTeacher(ws: CollabSocket) {
|
function isTeacher(ws: CollabSocket) {
|
||||||
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
|
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
|
||||||
}
|
}
|
||||||
@@ -64,7 +67,36 @@ export function handleCollabOpen(ws: CollabSocket) {
|
|||||||
|
|
||||||
export function handleCollabClose(ws: CollabSocket) {
|
export function handleCollabClose(ws: CollabSocket) {
|
||||||
if (isTeacher(ws)) removeTeacher(ws)
|
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) {
|
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||||||
@@ -95,6 +127,15 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
|||||||
case "help_cancel":
|
case "help_cancel":
|
||||||
handleHelpCancel(ws)
|
handleHelpCancel(ws)
|
||||||
return
|
return
|
||||||
|
case "accept":
|
||||||
|
await handleAccept(ws, message.studentId)
|
||||||
|
return
|
||||||
|
case "reject":
|
||||||
|
handleReject(ws, message.studentId)
|
||||||
|
return
|
||||||
|
case "leave":
|
||||||
|
handleLeave(ws)
|
||||||
|
return
|
||||||
default:
|
default:
|
||||||
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
|
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
|
||||||
}
|
}
|
||||||
@@ -161,5 +202,111 @@ function handleHelpCancel(ws: CollabSocket) {
|
|||||||
broadcastRequests()
|
broadcastRequests()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task 3 会把它换成真正的按房间转发。此刻房间还不存在,先收下不处理 */
|
async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||||
export function handleCollabBinary(_ws: CollabSocket, _data: Buffer | Uint8Array) {}
|
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
|
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() {
|
export function resetCollabState() {
|
||||||
requests.clear()
|
requests.clear()
|
||||||
teachers.clear()
|
teachers.clear()
|
||||||
|
rooms.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function queryInteger(
|
|||||||
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
|
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
|
||||||
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
|
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
|
||||||
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
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) 这类「是普通用户才受限」的判断 ——
|
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
|
||||||
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface SubmissionSocketData {
|
|||||||
token: string
|
token: string
|
||||||
/** 令牌桶,open 时初始化,见 allowMessage */
|
/** 令牌桶,open 时初始化,见 allowMessage */
|
||||||
rate?: { tokens: number; updatedAt: number }
|
rate?: { tokens: number; updatedAt: number }
|
||||||
/** 以下两项只有 kind === "collab" 时才填,握手时从会话里读 */
|
/** 握手时从会话里读,三种 kind 都会填;collab 通道用它判断老师身份、拼 room_open 里的姓名 */
|
||||||
username?: string
|
username?: string
|
||||||
adminType?: string
|
adminType?: string
|
||||||
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
|
/** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */
|
||||||
|
|||||||
Reference in New Issue
Block a user