# 课堂求助与协作编辑 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 学生就某道题发起求助,老师从全局列表接单并进入该学生的编辑器协作改代码;传输从 y-webrtc P2P 换成后端 WebSocket 通道,房间归属与权限由服务端判定。 **Architecture:** 新增 `/ws/collab` 通道,登录即连、全局常驻。服务端在内存里维护求助表与房间表,只按房间转发 Yjs 二进制帧、不解析代码内容。房间以**学生**为键 —— 学生是房间归属者,这与「学生的代码是内容源」是同一件事。CRDT 与光标仍用 yjs + y-codemirror.next,只是把 y-webrtc 这层传输换掉。 **Tech Stack:** 后端 Bun + Hono + Drizzle(`ws.data.kind` 区分通道,Bun 原生 pub/sub);前端 Vue 3 + Pinia + Naive UI + CodeMirror 6 + yjs / y-codemirror.next。 **Spec:** `docs/specs/2026-08-28-collab-help-request-design.md` ## Global Constraints - **不写测试。** 项目约定(`CLAUDE.md`):验证靠实跑。每个任务的收尾是本文写明的手动验证步骤,不是测试用例。 - **不新增数据库表、不写 drizzle 迁移。** 求助请求只在内存。 - **单二进制约束**:`apps/api` 用 `bun build --compile` 打包,运行时不能依赖 `node_modules` 里的文件路径解析(`require.resolve` / `__dirname` / `Bun.resolveSync`)。新增代码只用普通 import。 - **前端要兼容 Chrome < 94**(机房电脑)。不要引入新的语法/API 依赖,不动 vite 的构建 target。 - **服务端角色常量**:`TEACHER_ROLES = ["Teacher Admin", "Super Admin"]`(`apps/api/src/routes/helpers.ts:74`)。服务端判角色一律读库里的 `adminType`,**不认前端的演示模式**(`demoMode` 是纯 UI 概念)。 - **老师端永远不插入初始内容** —— 见 Task 7,这是硬规则不是默认值。 - **直接在 `main` 上提交**,不开特性分支。 - 提交信息用中文,与仓库现有风格一致。 --- ## 文件结构 **后端新增** | 文件 | 职责 | |---|---| | `apps/api/src/collab/state.ts` | 内存状态:求助表、房间表、在线老师集合。纯数据结构与查询,不碰 socket | | `apps/api/src/collab/handler.ts` | collab 通道的消息处理、鉴权、广播、二进制转发 | **后端修改** | 文件 | 改动 | |---|---| | `apps/api/src/websocket.ts` | `SubmissionSocketData` 加 collab 所需字段;`kind` 加 `"collab"`;限流分档;open/close/message 分派到 collab handler | | `apps/api/src/index.ts` | upgrade 分支加 `/ws/collab` | **前端新增** | 文件 | 职责 | |---|---| | `apps/web/src/shared/store/collab.ts` | pinia store:WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间。全局单例 | | `apps/web/src/shared/composables/collabDoc.ts` | Y.Doc + yCollab 绑定,把 Yjs 帧接到 collab store 的 WS 上 | | `apps/web/src/shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 | | `apps/web/src/shared/components/CollabModal.vue` | 老师端协作模态框 | **前端修改** | 文件 | 改动 | |---|---| | `apps/web/src/shared/composables/websocket.ts` | `BaseWebSocket` 支持二进制收发 | | `apps/web/src/App.vue` | 挂载全局 collab 连接 | | `apps/web/src/shared/components/Header.vue` | 嵌入 `HelpRequestList` | | `apps/web/src/oj/problem/components/Form.vue` | 「开启同步」→「求助 / 取消求助」 | | `apps/web/src/oj/problem/components/ProblemEditor.vue` | 去掉 syncStatus provide/inject,改读 store | | `apps/web/src/shared/components/SyncCodeEditor.vue` | 改用 collabDoc | | `apps/web/src/oj/problem/components/ContestEditor.vue` | 删掉空的 `provideSyncStatus()` | **前端删除**:`shared/composables/sync.ts`、`oj/composables/syncStatus.ts` 两点说明: - spec 里这个 composable 叫 `shared/composables/collab.ts`,本计划改名为 `collabDoc.ts` —— 与 `shared/store/collab.ts` 同名会让 import 路径极易看混。功能不变。 - 前端配了 `unplugin-auto-import`(`vite.config.ts:101`),**vue / vue-router / pinia 的 API 全部自动导入**。新文件里不要写 `import { ref, computed, watch } from "vue"` 或 `import { defineStore } from "pinia"`,现有的 store 与组件都没写。 --- ## Task 1: BaseWebSocket 支持二进制帧 现有客户端 `onmessage` 无条件 `JSON.parse(event.data)`(websocket.ts:158),`send()` 无条件 `JSON.stringify`(websocket.ts:325)—— 收到 Yjs 二进制帧会直接落进 catch 打一条「解析消息失败」。 先给基类开口子,后面的 collab 客户端才能复用它的重连、心跳、`force_logout` 处理。 **Files:** - Modify: `apps/web/src/shared/composables/websocket.ts` **Interfaces:** - Consumes: 无 - Produces: `BaseWebSocket` 新增 `protected onBinary(data: ArrayBuffer): void`(默认空实现,子类覆盖)与 `sendRaw(data: ArrayBuffer | Uint8Array): boolean` - [ ] **Step 1: 连接时声明二进制格式** 在 `connect()` 里 `new WebSocket(this.url)` 之后、绑定事件之前加一行。找到创建 socket 的那句, 紧跟着加: ```ts ws.binaryType = "arraybuffer" ``` 不加的话浏览器默认给 `Blob`,读取要走异步 `.arrayBuffer()`,Yjs 的消息顺序会乱。 - [ ] **Step 2: onmessage 里把二进制岔开** 把 websocket.ts:155 的 `ws.onmessage` 改成先判类型。原来的整段 JSON 逻辑保持不动,只在最前面插入: ```ts ws.onmessage = (event) => { if (ws !== this.ws) return // Yjs 这类二进制帧不是 JSON,交给子类。基类的 pong / force_logout 都是文本帧, // 不会走到这条路径上 if (typeof event.data !== "string") { this.onBinary(event.data as ArrayBuffer) return } try { const data = JSON.parse(event.data) as T // ...以下原样保留... ``` - [ ] **Step 3: 加 onBinary 钩子与 sendRaw** 在 `send(data: any)`(websocket.ts:325)旁边加两个成员: ```ts /** * 二进制帧钩子。基类不认二进制,默认丢弃;collab 这类通道在子类里覆盖。 * 和 onMessage 对称,不要在这里做 JSON 解析。 */ protected onBinary(_data: ArrayBuffer) {} /** * 不做 JSON 序列化的发送。Yjs 的 update / awareness 本身就是 Uint8Array, * 走 send() 会被 JSON.stringify 成一个 {"0":12,"1":3,...} 的对象。 */ sendRaw(data: ArrayBuffer | Uint8Array) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(data) return true } return false } ``` - [ ] **Step 4: 验证没有回归** ```bash bun run dev ``` 浏览器打开 `http://localhost:5173`,登录后: 1. 打开 devtools Network → WS,确认 `/ws/config` 与 `/ws/submissions` 照常连上、心跳 pong 正常 2. 提交一份代码,确认判题状态照常实时刷新(走 `/ws/submissions`) 3. Console 里不应出现 `[WebSocket] 解析消息失败` - [ ] **Step 5: 提交** ```bash git add apps/web/src/shared/composables/websocket.ts git commit -m "feat(web): BaseWebSocket 支持二进制帧收发 onmessage 原来无条件 JSON.parse,收到 Yjs 二进制帧会落进 catch。 加 onBinary 钩子与 sendRaw,让 collab 通道能复用基类的重连与心跳。" ``` --- ## Task 2: 后端 collab 通道 —— 求助表与控制面 只做控制面:学生发起/撤销求助,老师收到列表。房间与二进制转发在 Task 3。 **Files:** - Create: `apps/api/src/collab/state.ts` - Create: `apps/api/src/collab/handler.ts` - Modify: `apps/api/src/websocket.ts` - Modify: `apps/api/src/index.ts` **Interfaces:** - Consumes: `SubmissionSocketData`(websocket.ts:52)、`TEACHER_ROLES`(routes/helpers.ts:74)、`touchSession`(auth/session.ts:161) - Produces: - `state.ts`: `HelpRequest`、`CollabSocket` 类型;`addRequest`、`removeRequest`、`getRequest`、`listRequests`、`queueAheadOf`、`addTeacher`、`removeTeacher`、`hasTeacherOnline`、`teacherSockets` - `handler.ts`: `handleCollabOpen(ws)`、`handleCollabClose(ws)`、`handleCollabMessage(ws, raw)`、`handleCollabBinary(ws, data)`(Task 3 才填实现)、`broadcastRequests()` - [ ] **Step 1: 写 state.ts** Create `apps/api/src/collab/state.ts`: ```ts /** * 课堂求助的内存状态。 * * 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。 * 服务端只有一个 serve 进程(main.ts 单二进制 + 子命令,compose 里 oj-api 一个容器), * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 */ export type CollabSocket = Bun.ServerWebSocket 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() /** 在线老师的连接。用于推列表,也用于判断 no_teacher */ const teachers = new Set() 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() } ``` - [ ] **Step 2: 扩展 SubmissionSocketData** Modify `apps/api/src/websocket.ts:52`,给接口加三个字段(`kind` 加一个取值): ```ts export interface SubmissionSocketData { userId: number /** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分通道 */ kind: "submissions" | "config" | "collab" /** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */ token: string /** 令牌桶,open 时初始化,见 allowMessage */ rate?: { tokens: number; updatedAt: number } /** 以下两项只有 kind === "collab" 时才填,握手时从会话里读 */ username?: string adminType?: string /** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */ roomOwnerId?: number } ``` - [ ] **Step 3: 限流分档** collab 的二进制帧是每敲一个字一条,现有的 20 突发 / 每秒 2 个(websocket.ts:69-70)几秒就会 `ws.close(1008)` 把人踢掉。在 `RATE_REFILL_PER_SECOND` 下面加一组常量: ```ts /** * collab 通道的二进制帧(Yjs update / awareness)单独一档。 * * 它不查库、不解析,纯内存按房间转发,成本和文本控制帧完全不是一个量级; * 而连续快速输入大约 5-10 帧/秒,用严格档几秒钟就会把正在协作的人踢下线。 */ const COLLAB_BINARY_BURST = 200 const COLLAB_BINARY_REFILL_PER_SECOND = 100 ``` 把 `allowMessage` 改成接受档位参数: ```ts function allowMessage( ws: Bun.ServerWebSocket, burst = RATE_BURST, refillPerSecond = RATE_REFILL_PER_SECOND, ) { const now = Date.now() const rate = (ws.data.rate ??= { tokens: burst, updatedAt: now }) const refill = ((now - rate.updatedAt) / 1000) * refillPerSecond rate.tokens = Math.min(burst, rate.tokens + refill) rate.updatedAt = now if (rate.tokens < 1) return false rate.tokens -= 1 return true } ``` 注意:令牌桶是**每条连接一个**,而 collab 连接上文本帧很稀疏(求助、接单各一条), 所以两档共用同一个桶不会互相饿死 —— 二进制帧把桶按宽松档填,文本帧按严格档取, 实际效果是 collab 连接整体走宽松档。这是可以接受的:这条连接上的文本帧同样不查库 (`accept` 会查,但它一次会话只发一次)。 - [ ] **Step 4: 在 handler 里分派 collab** Modify `apps/api/src/websocket.ts` 的 `submissionWebSocketHandler()`。在 `open`、`message`、 `close` 三处加 collab 分支(放在各自函数最前面,collab 不订阅 submission/config 的 topic): ```ts open(ws) { liveSockets.add(ws) ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() } if (ws.data.kind === "collab") { handleCollabOpen(ws) return } if (ws.data.kind === "config") { ws.subscribe(configTopic) return } ws.subscribe(userSubmissionTopic(ws.data.userId)) ws.subscribe(userEventTopic(ws.data.userId)) }, message(ws, message) { if (ws.data.kind === "collab") { if (typeof message !== "string") { if (!allowMessage(ws, COLLAB_BINARY_BURST, COLLAB_BINARY_REFILL_PER_SECOND)) { ws.close(1008, "Too many messages") return } handleCollabBinary(ws, message) return } if (!allowMessage(ws)) { ws.close(1008, "Too many messages") return } handleCollabMessage(ws, message).catch((error) => { console.error("Failed to handle collab message", error) ws.send(JSON.stringify({ type: "error", message: "Internal error" })) }) return } // ...以下原有逻辑原样保留... }, close(ws) { liveSockets.delete(ws) if (ws.data.kind === "collab") { handleCollabClose(ws) return } // ...以下原有逻辑原样保留... }, ``` 文件顶部加 import: ```ts import { handleCollabBinary, handleCollabClose, handleCollabMessage, handleCollabOpen, } from "./collab/handler" ``` - [ ] **Step 5: 写 handler.ts 的控制面** Create `apps/api/src/collab/handler.ts`: ```ts 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 = {}, ) { 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 addRequest({ studentId: ws.data.userId, studentName: ws.data.username ?? "", 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() } ``` `className` 先填 `null`:它在 `user` 表上(schema.ts:663),握手时一并读出来更省事, 下一步就补。 - [ ] **Step 6: 握手时带上 username / adminType / className** `getRequestSessionUser`(auth/session.ts:142)返回的 user 已含 `adminType`(session.ts:106)。 Modify `apps/api/src/index.ts:103` 那段 upgrade 分支: ```ts if ( url.pathname === "/ws/submissions" || url.pathname === "/ws/config" || url.pathname === "/ws/collab" ) { if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) { return new Response("Forbidden", { status: 403 }) } const user = await getRequestSessionUser(request) if (!user) return new Response("Unauthorized", { status: 401 }) const kind = url.pathname === "/ws/config" ? "config" : url.pathname === "/ws/collab" ? "collab" : "submissions" if ( bunServer.upgrade(request, { data: { userId: user.id, kind, token: readRequestSessionToken(request), username: user.username, adminType: user.adminType, }, }) ) { return undefined } return new Response("WebSocket upgrade failed", { status: 400 }) } ``` `className` 不在会话里,在 `handleHelpRequest` 里和题目一起查出来。把上一步 state 写入 改成: ```ts 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, // ...其余不变 }) ``` - [ ] **Step 7: 验证控制面** ```bash cd /home/xuyue/Projects/OJ/OJ2 && bun run dev ``` 开两个浏览器 profile。**A 用教师账号登录,B 用学生账号登录**(`.env` 里的 `OJ2_DEV_USERNAME=student`)。两边 devtools Console 各跑: ```js const ws = new WebSocket(`ws://localhost:5173/ws/collab`) ws.onmessage = (e) => console.log("recv", e.data) ``` 然后: 1. **A(教师)**连上后应立刻收到一条 `{"type":"requests","list":[]}` 2. **B(学生)**发 `ws.send(JSON.stringify({type:"help_request",problemId:"1"}))` (`problemId` 换成一道真实存在的非比赛题的展示号) - B 收到 `{"type":"help_status","status":"pending","queueAhead":0}` - A 收到 `requests`,list 里有一条,含 `studentName`、`problemTitle`、`className` 3. **B** 发 `{"type":"help_cancel"}` → A 收到空 list 4. **B** 发 `{"type":"help_request",problemId:"不存在的号"}` → 收到 `题目不存在或不支持求助` 5. **A(教师)**发 `help_request` → 收到 `教师不能发起求助` 6. 关掉 A 的连接,B 再发 `help_request` → 收到 `{"status":"no_teacher"}` 7. B 连着不动 60 秒以上,确认没有被限流踢掉(`allowMessage` 改动没写坏严格档) - [ ] **Step 8: 提交** ```bash git add apps/api/src/collab apps/api/src/websocket.ts apps/api/src/index.ts git commit -m "feat(api): 新增 /ws/collab 通道与课堂求助控制面 学生发起/撤销求助,在线教师收到全量列表。求助只在内存,不落库。 二进制帧的限流单独一档,避免协作输入把连接踢掉。" ``` --- ## Task 3: 后端房间与二进制转发 **Files:** - Modify: `apps/api/src/collab/state.ts` - Modify: `apps/api/src/collab/handler.ts` **Interfaces:** - Consumes: Task 2 的全部导出 - Produces: - `state.ts`: `Room` 类型;`openRoom`、`getRoom`、`closeRoom`、`roomOf` - `handler.ts`: `handleCollabBinary` 的实现;`accept` / `reject` / `leave` 三个消息的处理 - [ ] **Step 1: state.ts 加房间表** 在 `apps/api/src/collab/state.ts` 末尾(`resetCollabState` 之前)加: ```ts 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) } ``` 把 `resetCollabState` 补上 `rooms.clear()`。 - [ ] **Step 2: handler.ts 加 accept / reject / leave** 在 `handleCollabMessage` 的 switch 里补三个分支: ```ts case "accept": await handleAccept(ws, message.studentId) return case "reject": handleReject(ws, message.studentId) return case "leave": handleLeave(ws) return ``` 然后加这四个函数: ```ts 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() } ``` `state.ts` 的新导出要加进顶部 import:`openRoom`、`closeRoom`、`roomOf`、`type Room`。 (`getRoom` 本任务用不到,别顺手加进去。) - [ ] **Step 3: 断线处理** 把 Task 2 里那个占位的 `handleCollabClose` 换成完整实现: ```ts export function handleCollabClose(ws: CollabSocket) { if (isTeacher(ws)) removeTeacher(ws) 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() } ``` - [ ] **Step 4: 二进制转发** ```ts /** * 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) } ``` - [ ] **Step 5: 验证房间与转发** `bun run dev`,仍用两个 profile 的 Console(教师 A / 学生 B): ```js const ws = new WebSocket(`ws://localhost:5173/ws/collab`) ws.binaryType = "arraybuffer" ws.onmessage = (e) => console.log("recv", typeof e.data === "string" ? e.data : new Uint8Array(e.data)) ``` 1. B 发 `help_request` → A 收到列表 2. A 发 `{"type":"accept","studentId":}` - 双方各收到一条 `room_open`,`peer.name` 分别是对方的用户名 - B 另收到 `{"type":"help_status","status":"active","teacherName":"..."}` - A 再收到 `requests`,那条的 `status` 变成 `active` 3. A 发二进制:`ws.send(new Uint8Array([1,2,3]))` → **B** 收到 `Uint8Array(3) [1,2,3]`;反向同样 4. A 再发一次 `accept`(换个学生)→ 收到 `请先退出当前协作` 5. A 发 `{"type":"leave"}` → 双方收到 `{"reason":"done"}`,A 的列表变空 6. 重新 accept 后**关掉 A 的标签页** → B 收到 `{"reason":"peer_offline"}`, 再开一个教师连接,列表里那条应回到 `pending` 7. 重新 accept 后**关掉 B 的标签页** → A 收到 `peer_offline`,且列表里那条消失 8. 未在房间里时发二进制 → 无任何转发、不报错 9. 用学生账号发 `{"type":"accept","studentId":1}` → 收到 `无权限` 10. 连续快速发 100 条二进制帧,确认连接没被 1008 踢掉(限流宽松档生效) - [ ] **Step 6: 提交** ```bash git add apps/api/src/collab git commit -m "feat(api): collab 房间管理与 Yjs 帧转发 accept 时按库里的 adminType 复核身份,房间以学生为键。 服务端只按房间转发二进制帧,不解析内容。 老师掉线请求退回排队,学生掉线请求随人清除。" ``` --- ## Task 4: 前端 collab store 与 WS 客户端 **Files:** - Modify: `apps/web/src/shared/composables/websocket.ts`(加 `CollabWebSocket` 类) - Create: `apps/web/src/shared/store/collab.ts` - Modify: `apps/web/src/App.vue` **Interfaces:** - Consumes: Task 1 的 `onBinary` / `sendRaw`;Task 2、3 的服务端协议 - Produces: `useCollabStore()`,导出 `connect()`、`disconnect()`、`requestHelp(problemId: string)`、`cancelHelp()`、 `accept(studentId: number)`、`reject(studentId: number)`、`leave()`、 `sendBinary(data: Uint8Array)`、`setBinaryHandler(fn: ((data: ArrayBuffer) => void) | null)`, 以及只读状态 `requests`、`helpStatus`、`queueAhead`、`teacherName`、`room` - [ ] **Step 1: 加 CollabWebSocket 类** 在 `apps/web/src/shared/composables/websocket.ts` 末尾(`useConfigWebSocket` 之后)加: ```ts export interface CollabRequestItem { studentId: number studentName: string className: string | null problemId: string problemTitle: string createdAt: number status: "pending" | "active" teacherName: string | null } export interface CollabMessage extends WebSocketMessage { type: | "requests" | "help_status" | "room_open" | "room_closed" | "error" } /** * 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** —— * 控制面是 JSON,Yjs 的 update / awareness 走 sendRaw 与 onBinary。 */ export class CollabWebSocket extends BaseWebSocket { private binaryHandler: ((data: ArrayBuffer) => void) | null = null constructor() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" super({ url: `${protocol}//${window.location.host}/ws/collab` }) } setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { this.binaryHandler = handler } protected override onBinary(data: ArrayBuffer) { this.binaryHandler?.(data) } } ``` - [ ] **Step 2: 写 store** Create `apps/web/src/shared/store/collab.ts`: ```ts import { CollabWebSocket, type CollabMessage, type CollabRequestItem, } from "shared/composables/websocket" import { useUserStore } from "shared/store/user" export type HelpStatus = "idle" | "pending" | "active" export interface RoomInfo { peerName: string peerRole: "student" | "teacher" problemId: string } /** * 课堂求助的全局状态。 * * 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助, * 学生也需要在等待期间一直挂着。所以这里不用 onUnmounted,由 App.vue 按登录态开关。 */ export const useCollabStore = defineStore("collab", () => { const userStore = useUserStore() const ws = new CollabWebSocket() /** 老师端:待处理列表 */ const requests = ref([]) /** 学生端:自己的求助状态 */ const helpStatus = ref("idle") const queueAhead = ref(0) const teacherName = ref("") /** 双方:当前房间。null 表示不在协作中 */ const room = ref(null) /** 一次性提示,由组件消费后清空 */ const notice = ref("") const pendingCount = computed( () => requests.value.filter((it) => it.status === "pending").length, ) /** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */ const groupedRequests = computed(() => { const groups = new Map() for (const item of requests.value) { const group = groups.get(item.problemId) if (group) group.items.push(item) else groups.set(item.problemId, { problemId: item.problemId, problemTitle: item.problemTitle, items: [item], }) } // 人多的题排前面;人数相同按最久等待排 return Array.from(groups.values()).sort( (a, b) => b.items.length - a.items.length || a.items[0].createdAt - b.items[0].createdAt, ) }) const handleMessage = (data: CollabMessage) => { switch (data.type) { case "requests": requests.value = (data.list ?? []) as CollabRequestItem[] return case "help_status": if (data.status === "pending") { helpStatus.value = "pending" queueAhead.value = Number(data.queueAhead ?? 0) } else if (data.status === "active") { helpStatus.value = "active" teacherName.value = String(data.teacherName ?? "") } else if (data.status === "cancelled") { helpStatus.value = "idle" notice.value = "老师已取消你的求助" } else if (data.status === "no_teacher") { helpStatus.value = "idle" notice.value = "当前没有老师在线" } return case "room_open": room.value = { peerName: String(data.peer?.name ?? ""), peerRole: data.peer?.role === "teacher" ? "teacher" : "student", problemId: String(data.problemId ?? ""), } return case "room_closed": room.value = null // 老师掉线时服务端会另发一条 help_status:pending,这里不抢着改学生状态 if (data.reason === "done") helpStatus.value = "idle" notice.value = data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束" return case "error": notice.value = String(data.message ?? "") return } } ws.addHandler(handleMessage) function connect() { ws.connect() } function disconnect() { ws.disconnect() requests.value = [] helpStatus.value = "idle" room.value = null } function requestHelp(problemId: string) { ws.send({ type: "help_request", problemId }) } function cancelHelp() { ws.send({ type: "help_cancel" }) helpStatus.value = "idle" } function accept(studentId: number) { ws.send({ type: "accept", studentId }) } function reject(studentId: number) { ws.send({ type: "reject", studentId }) } function leave() { ws.send({ type: "leave" }) } function sendBinary(data: Uint8Array) { ws.sendRaw(data) } function setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { ws.setBinaryHandler(handler) } function consumeNotice() { const value = notice.value notice.value = "" return value } return { requests, pendingCount, groupedRequests, helpStatus, queueAhead, teacherName, room, notice, isTeacher: computed(() => userStore.isTeacherOrAbove), connect, disconnect, requestHelp, cancelHelp, accept, reject, leave, sendBinary, setBinaryHandler, consumeNotice, } }) ``` - [ ] **Step 3: App.vue 挂全局连接** 在 `apps/web/src/App.vue` 的 `useConfigUpdate()` / `useMaxKB()` 附近加: ```ts import { useCollabStore } from "shared/store/collab" const collabStore = useCollabStore() // 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时 // 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落 watch( () => userStore.isAuthed, (isAuthed) => { if (isAuthed) collabStore.connect() else collabStore.disconnect() }, { immediate: true }, ) ``` - [ ] **Step 4: 验证** `bun run dev`,两个 profile 登录后: 1. devtools Network → WS 里能看到 `/ws/collab` 连上,且**任何页面**(首页、后台、题目页)都在 2. 教师端 Console:`useCollabStore` 不好直接取,改在 Vue devtools 里看 pinia 的 `collab` store —— `requests` 初始为空数组 3. 学生端 Console 手动发一条求助(借用上一个任务的裸 WS 方式即可),教师端 store 的 `requests` 应实时出现一条,`pendingCount` 变 1 4. 学生登出 → 连接断开;重新登录 → 自动重连 5. 停掉 api(`Ctrl-C`)再起来 → 前端应自动重连(`BaseWebSocket` 默认无限重连) - [ ] **Step 5: 提交** ```bash git add apps/web/src/shared/composables/websocket.ts apps/web/src/shared/store/collab.ts apps/web/src/App.vue git commit -m "feat(web): 课堂求助 store 与全局 collab 连接 连接全局常驻,不跟题目页起落 —— 老师在任何页面都要能收到求助。 列表按题目聚合,同题多人时能一眼看出该全班讲。" ``` --- ## Task 5: 学生端求助按钮 **Files:** - Modify: `apps/web/src/oj/problem/components/Form.vue` - Modify: `apps/web/src/oj/problem/components/ProblemEditor.vue` **Interfaces:** - Consumes: Task 4 的 `useCollabStore()` - Produces: 学生能从题目页发起与撤销求助;`Form.vue` 不再 emit `toggleSync`,也不再 `injectSyncStatus` - [ ] **Step 1: Form.vue 换掉同步按钮** 删掉这些 import 与状态: ```ts import { injectSyncStatus } from "oj/composables/syncStatus" import { SYNC_MESSAGES } from "shared/composables/sync" // ... const syncStatus = injectSyncStatus() const syncEnabled = ref(false) ``` 以及 `emit` 里的 `toggleSync`、`toggleSync()` 函数、`defineExpose({ resetSyncStatus })`、 `Props` 里的 `isConnected`。 换成: ```ts import { useCollabStore } from "shared/store/collab" const collabStore = useCollabStore() // 可见条件沿用原来的 showSyncFeature,再加上「不是教师」—— // 教师端的入口在顶栏,不在题目页 const showHelpButton = computed( () => isDesktop.value && userStore.isAuthed && !userStore.isTeacherOrAbove && codeStore.code.language !== "Flowchart" && !isContestMode.value, ) const helpButtonText = computed(() => { if (collabStore.helpStatus === "active") return "老师正在帮你" if (collabStore.helpStatus === "pending") return "取消求助" return "求助" }) const toggleHelp = () => { if (collabStore.helpStatus === "pending") collabStore.cancelHelp() else if (collabStore.helpStatus === "idle") collabStore.requestHelp(problem.value!._id) } // 服务端的一次性提示(没有老师在线、老师取消了求助) watch( () => collabStore.notice, (text) => { if (text) message.info(collabStore.consumeNotice()) }, ) ``` 模板里把原来 `