From 59191c9433066a49ed0df3d416b3ba4e02640fee Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 28 Aug 2026 02:00:37 -0600 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=AF=BE=E5=A0=82=E6=B1=82=E5=8A=A9?= =?UTF-8?q?=E4=B8=8E=E5=8D=8F=E4=BD=9C=E7=BC=96=E8=BE=91=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 个任务:前端 WS 基类支持二进制 → 后端 collab 通道控制面 → 房间与帧转发 → 前端 store → 学生求助按钮 → 教师顶栏列表 → 协作编辑 → 清理旧实现。 每个任务收尾是手动验证步骤(项目约定不写测试)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd --- docs/plans/2026-08-28-collab-help-request.md | 2009 ++++++++++++++++++ 1 file changed, 2009 insertions(+) create mode 100644 docs/plans/2026-08-28-collab-help-request.md diff --git a/docs/plans/2026-08-28-collab-help-request.md b/docs/plans/2026-08-28-collab-help-request.md new file mode 100644 index 0000000..33c11a4 --- /dev/null +++ b/docs/plans/2026-08-28-collab-help-request.md @@ -0,0 +1,2009 @@ +# 课堂求助与协作编辑 实现计划 + +> **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()) + }, +) +``` + +模板里把原来 `