feat(api): 新增 /ws/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:24:44 -06:00
parent c04570b4ad
commit 1db2c49b87
4 changed files with 318 additions and 8 deletions

View File

@@ -0,0 +1,165 @@
import { and, eq, isNull } from "drizzle-orm"
import { touchSession } from "../auth/session"
import { db, schema } from "../db"
import {
addRequest,
addTeacher,
getRequest,
hasTeacherOnline,
listRequests,
queueAheadOf,
removeRequest,
removeTeacher,
teacherSockets,
type CollabSocket,
type HelpRequest,
} from "./state"
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
}
/** 推给老师的列表条目。不含 socket也不含任何代码内容 */
function serializeRequest(request: HelpRequest) {
return {
studentId: request.studentId,
studentName: request.studentName,
className: request.className,
problemId: request.problemId,
problemTitle: request.problemTitle,
createdAt: request.createdAt,
status: request.status,
teacherName: request.teacherName ?? null,
}
}
export function broadcastRequests() {
const payload = JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
})
for (const ws of teacherSockets()) ws.send(payload)
}
function sendHelpStatus(
ws: CollabSocket,
status: "pending" | "active" | "cancelled" | "no_teacher",
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) }),
)
}
}
export function handleCollabClose(ws: CollabSocket) {
if (isTeacher(ws)) removeTeacher(ws)
// 房间与请求的清理在 Task 3 补
}
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
let message: { type?: unknown; problemId?: unknown; studentId?: unknown }
try {
message = JSON.parse(raw) as typeof message
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
return
}
// 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }))
return
}
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
if (!(await touchSession(ws.data.token))) {
ws.close(1008, "Session expired")
return
}
switch (message.type) {
case "help_request":
await handleHelpRequest(ws, message.problemId)
return
case "help_cancel":
handleHelpCancel(ws)
return
default:
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
}
}
async function handleHelpRequest(ws: CollabSocket, problemId: unknown) {
if (typeof problemId !== "string" || !problemId) {
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" }))
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({ 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] = await db
.select({ className: schema.user.className })
.from(schema.user)
.where(eq(schema.user.id, ws.data.userId))
.limit(1)
addRequest({
studentId: ws.data.userId,
studentName: ws.data.username ?? "",
className: student?.className ?? null,
problemId,
problemTitle: problem.title,
createdAt: Date.now(),
status: "pending",
socket: ws,
})
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
broadcastRequests()
}
function handleHelpCancel(ws: CollabSocket) {
const request = getRequest(ws.data.userId)
if (!request || request.status === "active") return
removeRequest(ws.data.userId)
broadcastRequests()
}
/** Task 3 会把它换成真正的按房间转发。此刻房间还不存在,先收下不处理 */
export function handleCollabBinary(_ws: CollabSocket, _data: Buffer | Uint8Array) {}

View File

@@ -0,0 +1,83 @@
/**
* 课堂求助的内存状态。
*
* 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。
* 服务端只有一个 serve 进程main.ts 单二进制 + 子命令compose 里 oj-api 一个容器),
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/
export type CollabSocket = Bun.ServerWebSocket<import("../websocket").SubmissionSocketData>
export interface HelpRequest {
studentId: number
studentName: string
className: string | null
/** 题目的展示号problem._id 列,前端一路用的都是它),不是自增主键 */
problemId: string
problemTitle: string
createdAt: number
status: "pending" | "active"
teacherId?: number
teacherName?: string
socket: CollabSocket
}
/** 求助表,以学生为键 —— 一个学生同时只有一个求助 */
const requests = new Map<number, HelpRequest>()
/** 在线老师的连接。用于推列表,也用于判断 no_teacher */
const teachers = new Set<CollabSocket>()
export function addRequest(request: HelpRequest) {
requests.set(request.studentId, request)
}
export function getRequest(studentId: number) {
return requests.get(studentId)
}
export function removeRequest(studentId: number) {
return requests.delete(studentId)
}
export function hasRequest(studentId: number) {
return requests.has(studentId)
}
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
export function listRequests() {
return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt)
}
/** 比自己早创建、且仍在排队的请求数 */
export function queueAheadOf(studentId: number) {
const self = requests.get(studentId)
if (!self) return 0
let ahead = 0
for (const request of requests.values()) {
if (request.status === "pending" && request.createdAt < self.createdAt) ahead += 1
}
return ahead
}
export function addTeacher(ws: CollabSocket) {
teachers.add(ws)
}
export function removeTeacher(ws: CollabSocket) {
teachers.delete(ws)
}
export function hasTeacherOnline() {
return teachers.size > 0
}
export function teacherSockets() {
return teachers
}
/** 仅供进程退出或测试用,正常路径不该调 */
export function resetCollabState() {
requests.clear()
teachers.clear()
}