Files
OJ2/docs/specs/2026-08-28-collab-help-request-design.md
yuetsh a8408c0bb5 docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物
CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题
文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、
drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md
(出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。

删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、
endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、
docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前
先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、
NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚
两节随旧栈下线一并作废。

两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。

apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量
早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服),
store 与 composable 清单补齐到与目录一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:26 -06:00

15 KiB
Raw Blame History

OJ2 设计文档:课堂求助与协作编辑

日期2026-08-28 状态:已实施。第 1 节写的是重做之前的实现y-webrtc 那套,sync.ts / syncStatus.ts 早已删除),留着是为了说明为什么要改;现状是第 3 节往后。

1. 背景

现有的「协同编辑」功能从 ojnext 原样搬进 apps/webcommit ae1fb32,一行未改), 用 y-webrtc 做点对点同步,信令走仓库外的 wss://signaling.xuyue.cc

涉及文件:

文件 职责
apps/web/src/shared/composables/sync.ts Y.Doc + WebrtcProvider + yCollab房间权限状态机
apps/web/src/shared/components/SyncCodeEditor.vue CodeMirror 挂载点,sync prop 驱动启停
apps/web/src/oj/composables/syncStatus.ts provide/inject 的对端用户状态
apps/web/src/oj/problem/components/Form.vue 「开启同步 / 断开同步」按钮与状态标签
apps/web/src/oj/problem/components/ProblemEditor.vue 组装
apps/web/src/oj/problem/components/ContestEditor.vue 仅为避免 inject 抛错而空 provide

后端对此功能零参与。

1.1 为什么要重做

本功能的实际用途是课堂辅导:学生做题卡住,老师进入他的编辑器一起改。 按这个用途重新审视,现有实现有四个结构性问题:

a. 老师无法指定帮谁。 房间名是 problem-${problemId}sync.ts:272 不含用户身份。一个班几十人做同一道题时,老师点「开启同步」进的是「第 N 题房间」, 和谁连上取决于谁先在房间里。这不是缺陷,是这个功能没有实现「帮谁」这个概念

b. 谁的代码保留是随机的。 setupContentSyncsync.ts:230-249两端完全对称 各自等 500ms谁先发现 ytext 为空谁就把自己的内容插进去。老师接入可能覆盖学生 正在写的代码;两端同时超时还会把两份代码拼在一起。

c. 权限判定是客户端自报,且拦不住。 isSuperAdmin 写在 awareness 里 sync.ts:344任何人都能在 devtools 里改。而且判定分支只覆盖 roomUsers === 2 && !hasSuperAdminsync.ts:180—— 三人及以上落进 else 分支, 提示「正在等待超管加入」但 yCollab 扩展不会被摘掉,三个人一直在同步编辑。 即使判定命中,它也发生在扩展挂载、文档交换之后

d. 外部依赖无处可管。 signaling.xuyue.ccdocker/ 下没有任何部署源 compose、Caddyfile、deploy.sh 均无),仓库里查不到它怎么起、怎么重启。 .env.production 之外的三个环境都指向内网 IP 10.13.114.114:8085。 另外 WebrtcProvider 未配置任何 ICE/TURNsync.ts:274-278跨 NAT 场景 只能依赖 simple-peer 默认的 Google STUN。

根因是同一件事:服务端不知道谁是谁,所以身份、权限、房间归属只能由客户端 自行声明,于是 sync.ts 里写了两百行自报身份 + 人数猜测的状态机,既不可靠也无约束力。

2. 目标与非目标

目标

  • 学生可以就某道题发起求助;老师能看到求助列表并进入协作。
  • 房间归属与权限由服务端判定,不再依赖客户端自报。
  • 协作起点确定为学生的代码,不存在覆盖与竞态。
  • 传输改走后端已有的 WebSocket 层,去掉 y-webrtc 与外部信令服务器。

非目标

  • 不做「老师主动连线」:老师不能在学生未求助时进入其编辑器。若将来要做, 需要额外的在线学生列表与学生端知情提示,属另一个设计。
  • 不做「老师演示给全班看」1 对 N 广播是另一套模型,本次不涉及。
  • 不落库:求助请求只在内存,不新增表、不动 drizzle 迁移。
  • 不改判题、提交、比赛任何流程。
  • 比赛模式仍然不提供本功能(沿用 Form.vueisContestMode 判断,并在服务端 一并拒绝比赛题的求助)。

3. 交互流程

学生                          服务端                        老师
 │                              │                            │
 ├── help_request{problemId} ──►│                            │
 │                              ├── requests{list} ─────────►│  顶栏红点 +1
 │◄── help_status{pending, ─────┤                            │
 │      queueAhead:2}           │                            │
 │                              │◄── accept{studentId} ──────┤  点击某条
 │◄── room_open{peer} ──────────┼── room_open{peer} ────────►│  弹出协作模态框
 │                              │                            │
 │◄═══ Yjs 二进制帧(服务端按房间转发,不解析)═══════════════►│
 │                              │                            │
 │◄── room_closed{done} ────────┼◄── leave ──────────────────┤  老师关闭模态框

3.1 多人同时求助

老师端是一个排队列表,不是弹窗。顶栏红点显示待处理数量,点开是列表。

  • 按等待时长排序,但不强制先来先到。 老师点谁就是谁 —— 上课时有的问题一句话 说清、有的要讲五分钟,强制 FIFO 只会碍事。
  • 同题聚合:同一道题有多人求助时,列表按题目分组显示(第5题 冒泡排序 · 3人 展开是该题下的学生。这本身是个教学信号:一道题堆了好几个人,说明该停下来全班讲, 而不是挨个救。
  • 老师一次只进一个房间。 其余请求继续排队,不受影响。
  • 多老师在线时,请求被接走后在其他老师列表里变灰并标注「李老师处理中」,不会撞车。

3.2 学生端状态

学生必须随时看到自己的处境,否则会反复点:

状态 学生看到
pending 「已求助,前面还有 N 人」
active 「X 老师正在帮你」
no_teacher 「当前没有老师在线」(点击求助时立即告知,不让他干等)
cancelled 「老师已取消你的求助」,可重新求助

queueAhead = 比自己早创建、且仍为 pending 的请求数。cancelledno_teacher 是推给学生的瞬时通知,不是服务端存储的状态 —— 请求在服务端只有 pendingactive 两态,取消即从表中移除。

3.3 老师取消请求

老师可在列表里对某条点 × 取消,请求直接消失,不建立连线,学生收到 cancelled 提示。适用于:学生举手后自己想明白了没撤销、老师已当面讲过、乱点。

这与「帮完了」是两回事 —— 帮完是老师退出房间(leave),请求一并清除。

4. 服务端设计

4.1 复用现有 WebSocket 基建

后端只有一个 serve 进程(main.ts 单二进制 + 子命令;docker/compose.debian.ymloj-api 一个容器,oj-worker 不跑 HTTP因此内存态房间可行,不需要 Redis 同步。进程重启丢掉全部房间,两端重连后回到干净状态。

新增通道 /ws/collab,沿用现有结构:

  • apps/api/src/index.ts:103 的 upgrade 分支加一条路径,握手时校验 origin 与会话, 把 userId / token 放进 ws.data
  • SubmissionSocketData.kind 增加 "collab"(该字段的存在理由就是「同一个 Bun.serve 只能挂一个 websocket handler用它区分通道」
  • 会话中途失效踢人(sweepSessions)、touchSession 复验、账号禁用检查全部白拿。

新增目录 apps/api/src/collab/

文件 职责
state.ts 内存状态:求助表、房间表、在线老师集合
handler.ts collab 通道的消息处理与广播

4.2 限流必须分档(关键坑)

现有令牌桶是 20 突发 / 每秒 2 个(websocket.ts:69-81),且 allowMessage 在 消息类型判断之前就拦截,超额直接 ws.close(1008, "Too many messages")

协作编辑时每敲一个字就是一条 Yjs 帧,几秒钟就会把连接踢掉。

方案collab 通道分两档 ——

  • 二进制帧Yjs 数据)走宽松档。它不查库、不解析,纯内存转发,成本极低。
  • 文本控制帧help_request / accept 等)沿用严格档。它们会查库。

起始阈值:二进制帧 200 突发 / 每秒 100 个(连续快速输入约 5-10 帧/秒,余量十倍以上), 文本帧沿用 20 / 2。实测再调。

4.3 消息协议

控制面是文本 JSON数据面是二进制帧Yjs update / awareness服务端不解析

客户端 → 服务端

消息 发送方 说明
{type:"help_request", problemId} 学生 发起求助
{type:"help_cancel"} 学生 撤销自己的求助
{type:"accept", studentId} 老师 接单
{type:"reject", studentId} 老师 取消某条请求
{type:"leave"} 双方 退出房间
{type:"ping"} 双方 沿用现有心跳,不查库
二进制帧 房间内双方 转发给房间里的另一个人

服务端 → 客户端

消息 接收方 说明
{type:"requests", list} 所有在线老师 全量列表(表很小,不做增量)
{type:"help_status", status, queueAhead?, teacherName?} 发起求助的学生 见 3.2
{type:"room_open", peer:{name, role}, problemId} 房间内双方 建立协作
{type:"room_closed", reason} 房间内双方 done(老师主动结束)/ peer_offline(对方断线)
{type:"pong", timestamp} 双方 心跳应答
二进制帧 房间内另一方 转发

4.4 权限

  • accept / reject 由服务端查库校验发送者是 isTeacherOrAbove不读客户端 声明的任何身份字段。注意用真实身份判定,不受前端演示模式影响 user.ts:43isSuperAdmin!demoMode,那是纯 UI 概念,服务端不认)。
  • 二进制帧只转发给同一房间的另一个成员,不做任何广播。
  • 比赛题的 help_request 服务端直接拒绝。
  • 一个学生同时只有一个求助;一个老师同时只在一个房间。

4.5 内存状态

requests: Map<studentId, {
  studentId, username, className, problemId, problemTitle,
  createdAt, status: "pending" | "active", teacherId?
}>

rooms: Map<studentId, { studentSocket, teacherSocket, problemId }>

teacherSockets: Set<socket>   // 在线老师,用于推 requests 与判断 no_teacher

房间以学生为键 —— 学生是房间的归属者,这与「学生的代码是内容源」是同一件事。

5. 前端设计

5.1 新增

文件 职责
shared/store/collab.ts pinia store持有 WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间
shared/components/HelpRequestList.vue 顶栏红点 + 下拉列表,含同题聚合
shared/components/CollabModal.vue 老师端协作模态框
shared/composables/collab.ts Y.Doc 与 yCollab 绑定,取代 sync.ts

5.2 改动

文件 改动
App.vue 登录后挂载全局 collab 连接(照 useConfigUpdate() 的写法)
Header.vue 嵌入 HelpRequestList,仅老师及以上可见
Form.vue 「开启同步 / 断开同步」按钮改为「求助 / 取消求助」。可见条件沿用现有 showSyncFeature 的三条(桌面端、已登录、非流程图、非比赛),再加上「非教师角色」
SyncCodeEditor.vue 改为消费新的 collab composable
ProblemEditor.vue 去掉 syncStatus 的 provide/inject改读 store

5.3 删除

  • shared/composables/sync.ts
  • oj/composables/syncStatus.ts
  • ContestEditor.vue 里为避免 inject 抛错而写的空 provideSyncStatus()
  • y-webrtc 依赖(apps/web/package.json
  • PUBLIC_SIGNALING_URL.env / .env.production / .env.staging / .env.test / env.d.ts / apps/web/CLAUDE.md 环境变量表

保留 yjsy-codemirror.next —— CRDT 与光标显示继续用它们。

5.4 老师在模态框里编辑,不跳转页面

老师接单后弹出一个大模态框,内含协作编辑器,标题显示学生名、题号与题面链接。

理由:顶栏是全局的,老师接单时可能正在后台改题或看统计,跳转会丢掉他的上下文; 且老师自己题目页里的代码不会被搅乱。

6. 内容源规则(硬性)

学生点「求助」时不做任何事 —— 不清空编辑器、不建 Y.Doc、不连房间。 只有老师接单、room_open 到达之后才:

  1. 学生端:以当前编辑器内容建 Y.Doc 并插入,挂 yCollab。
  2. 老师端:建空 Y.Doc 挂 yCollab等学生端同步过来。
  3. 老师端永远不插入初始内容。

第 3 条是硬规则,不是默认值。它根治了 1.1(b)。

房间关闭时两端摘掉 yCollab 扩展,学生编辑器保留当前内容 —— 老师帮改的代码留在 学生那里,这是期望行为。学生端照常写 localStorage。

7. 边界与失败处理

情况 处理
老师断线 房间立即销毁,请求退回 pending 重新排队,学生不必重新点(可能只是网络抖动);学生端摘掉 yCollab编辑器保留当前内容
学生断线 / 关页面 房间销毁、请求清除,老师端收到 peer_offline
API 重启 内存全清;两端 WS 重连后回到干净状态,学生需重新求助
没有老师在线 学生点求助时当场返回 no_teacher
老师尝试进已被接走的请求 服务端拒绝,返回最新列表
会话失效 / 账号禁用 沿用现有 sweepSessionstouchSession 路径断连

8. 验证

项目约定不写测试,验证靠实跑。

bun run dev 起 api + worker + web开两个浏览器 profile一个学生账号、一个教师 账号),走完整流程:

  1. 学生求助 → 老师顶栏出现红点与列表项
  2. 多个学生求助 → 排序、同题聚合、等待时长显示正确
  3. 老师接单 → 双方进入房间,学生代码出现在老师模态框,两端互相看得到光标
  4. 双向编辑 → 内容一致,无覆盖
  5. 老师退出 → 学生编辑器保留改后的代码
  6. 老师取消某条请求 → 该学生收到提示
  7. 断线场景:学生关页面、老师关页面、重启 api
  8. 无老师在线时求助 → 学生立即收到提示
  9. 比赛题不出现求助按钮,且直接构造 help_request 被服务端拒绝

9. 遗留与后续

  • 求助不落库,因此没有「谁经常卡住、卡在哪道题」的历史统计。若将来需要教学反馈, 再加表不迟,本次刻意不做。
  • 「老师主动连线」与「老师演示给全班看」见 2. 非目标,各自需要独立设计。
  • apps/web/CLAUDE.md 整份仍是 ojnext 时代的内容npm 命令、指向 ../OnlineJudge 的 Django 后端其中「Yjs + y-webrtc for collaborative editing in the flowchart editor」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把 实时特性与环境变量两节改对,整份文档的翻新另计。