Files
OJ2/docs/specs/2026-08-28-collab-help-request-design.md
yuetsh 88b695d34f
Some checks failed
Deploy / deploy (push) Has been cancelled
feat(课堂求助): 教师端接单直接跳题目页协作,删掉 CollabModal
点求助列表里的一条 = 跳到 /problem/:id,在页面自带的那个编辑器里和学生同步。
原来是弹一个 CollabModal(弹框里再挂一个 CodeMirror + 一份只读题面):

- 按一下 Esc 弹框就关、协作跟着结束(`show` 的 setter 里 leave 是唯一的关闭语义,
  Esc 和点遮罩都走它),上课太容易误触;
- 弹框里那份题面不能跑测试、不能提交,老师还是得另开标签页对照。

现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。教师端也少了
一个 CodeMirror 实例 —— setBinaryHandler 那个单例槽位只剩 SyncCodeEditor 一个使用者。

改造中撞出两个真问题,根因是同一个:`/problem/1002` → `/problem/1001` 是同一条路由
换 params,**组件被复用、不卸载**。

- 老师在别的题上接单跳过来,`@ready` 不会再触发,而 room 早在导航前就 open 了 ——
  老师停在一个空编辑器上干等。watch 的源从 room 一个变成 [room, roomIsHere] 两个。
- 反过来老师协作中切去别的题,onUnmounted 不触发 → 不发 leave,学生一直挂着
  「老师正在帮你」而老师的字一个也过不去。加 `bound` 标记区分「房间从我这儿挪走了」
  (补发 leave)和「房间在别处开起来了」(跟我无关,接单那一瞬间就是这种)。

`leave` 分两种语义(协议加 reason):

| 前端 | 求助记录 | 对面看到 |
|---|---|---|
| leave("done") 点「结束协作」 | 删除 | 老师已结束这次帮忙 |
| leave("left") 离开这道题的页面 | 教师走→退回排队;学生走→删除 | 老师暂时离开,已重新排队 |

"left" 复用的是老师掉线那条路(requeueAfterTeacherGone),两件事语义相同。原来两者
都按 done 处理 —— 老师点一下「提交信息」,学生就得重新举手,而他看到的是
「协作已结束」,会以为被处理完了。发起方收到的 reason 单独一份(self_left/done),
不然学生自己切走了却看到「老师已结束这次帮忙」。

其余为这套交互补的闸:

- 学生排队期间离开那道题 → 自动取消求助。那条求助说的是「我卡在这道题」,人走了
  就不成立;原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本
  不会绑,老师对着空编辑器敲字,两边都没提示。
- 协作中**教师的语言选择跟着 room.language 走**(写 codeStore,不只是改高亮),
  结束后还原。只改高亮的话老师会拿自己那档语言提交学生的代码(学生写 C、老师选的
  是 Python,当场 CE),工具栏还可能显示「提交流程图」。学生端一个字不动 ——
  服务端的 room_language 只发给教师,学生本地那份停在建房那一刻。
- 协作中不给「重置代码」:v-model 一写回就顺着 Yjs 同步过去,等于一键清空学生的作业。
- 协作中「提交信息」走新标签 —— 那是教师工具栏上唯一会跳路由的按钮。
- 接单时 resetScreenMode():分屏在「题目」「自测」两档时右侧编辑器根本没挂出来,
  停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 detail.vue 的 init()
  会重置,同页不会)。
- 老师已在一个房间里时前端也拦住接下一单(服务端本来就拦),不拦的话前端已经跳到
  新题目上,等于把手上那场协作断掉。
- 文案:协议层那四条英文(Invalid problemId 之类)会被原样弹成 toast,改成中文;
  「请先退出当前协作」统一成「结束」;room_closed 的提示按 reason × 角色展开。

验证:本机起 api + web,三个账号两两组合实跑 —— 跨题接单 / 同页接单、双向编辑、
连按 Esc、结束协作、老师跳走后再接、学生跳走、学生排队中切题、学生协作中切语言、
分屏在「题目」档接单、协作中重复接单,以及上面每一句提示的实际文案。
api typecheck / check:routes、前端 type-check / build、fmt 全过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:13:42 -06:00

387 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OJ2 设计文档:课堂求助与协作编辑
日期2026-08-28
状态:已实施。**第 1 节写的是重做之前的实现**y-webrtc 那套,`sync.ts` / `syncStatus.ts`
早已删除),留着是为了说明为什么要改;现状是第 3 节往后。
## 1. 背景
现有的「协同编辑」功能从 ojnext 原样搬进 `apps/web`commit `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. 谁的代码保留是随机的。** `setupContentSync`sync.ts:230-249两端完全对称
各自等 500ms谁先发现 `ytext` 为空谁就把自己的内容插进去。老师接入可能覆盖学生
正在写的代码;两端同时超时还会把两份代码拼在一起。
**c. 权限判定是客户端自报,且拦不住。** `isSuperAdmin` 写在 awareness 里
sync.ts:344任何人都能在 devtools 里改。而且判定分支只覆盖
`roomUsers === 2 && !hasSuperAdmin`sync.ts:180—— 三人及以上落进 else 分支,
提示「正在等待超管加入」但 yCollab 扩展不会被摘掉,三个人一直在同步编辑。
即使判定命中,它也发生在扩展挂载、文档交换**之后**。
**d. 外部依赖无处可管。** `signaling.xuyue.cc``docker/` 下没有任何部署源
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.vue``isContestMode` 判断,并在服务端
一并拒绝比赛题的求助)。
## 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` 的请求数。`cancelled``no_teacher`
是推给学生的**瞬时通知**,不是服务端存储的状态 —— 请求在服务端只有 `pending`
`active` 两态,取消即从表中移除。
### 3.3 老师取消请求
老师可在列表里对某条点 **× 取消**,请求直接消失,不建立连线,学生收到
`cancelled` 提示。适用于:学生举手后自己想明白了没撤销、老师已当面讲过、乱点。
这与「帮完了」是两回事 —— 帮完是老师退出房间(`leave`),请求一并清除。
## 4. 服务端设计
### 4.1 复用现有 WebSocket 基建
后端只有一个 serve 进程(`main.ts` 单二进制 + 子命令;`docker/compose.debian.yml`
`oj-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:43``isSuperAdmin``!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`~~ | 老师端协作模态框。**2026-09-16 删除**,见文末的变更 |
| `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` 环境变量表
保留 `yjs``y-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` |
| 老师尝试进已被接走的请求 | 服务端拒绝,返回最新列表 |
| 会话失效 / 账号禁用 | 沿用现有 `sweepSessions``touchSession` 路径断连 |
## 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**」一句本就是错的(协作在代码编辑器,流程图被显式排除)。本次至少要把
实时特性与环境变量两节改对,整份文档的翻新另计。
## 变更记录
### 2026-09-16教师端不再用弹框接单直接跳题目页
老师点求助列表里的一条 → 跳到 `/problem/:id`,就在**页面自带的那个编辑器**里
和学生同步。`CollabModal.vue` 删除。
为什么改:
- **弹框按一下 Esc 就关,协作跟着结束。** 上课时太容易误触 —— `show` 的 setter
`leave()` 是唯一的关闭语义n-modal 的 Esc / 点遮罩都会走到它。
现在结束协作只有两条明确的路:工具栏的「结束协作」按钮、离开这一页。
- 弹框里那份题面是只读复制品(不能跑测试、不能提交),老师还是得另开一个标签页。
跳到真页面之后,老师看到的就是学生看到的那一页。
- 少一个 CodeMirror 实例:`setBinaryHandler` 那个单例槽位现在只有
`SyncCodeEditor` 一个使用者,教师端也不用再为了首屏体积把整套编辑器拆成
异步 chunk页面本来就要加载它
连带的口径:
- `SyncCodeEditor` 多一个 `problemId` prop**房间的题号对不上就不绑**(老师接单时
可能停在别的题上);种子内容仍然只来自学生端(教师端传 `null`)。
- 协作期间教师那个编辑器的语言跟着 `room.language` 走,语言选择器禁用;
草稿不写 localStorage那会儿编辑器里是学生的代码协作结束后读回教师自己的草稿。
- 协作中的题目页强制走代码编辑器分支,不受教师自己选的「流程图」影响。
### 2026-09-16同日第二轮`leave` 分两种语义
「老师走开」和「老师处理完了」原来都走 `leave``teardownRoom("done")` → 删掉求助
记录,于是老师点一下「提交信息」,学生就得重新举手,而且他看到的是「协作已结束」,
会以为自己被处理完了。
协议加一个 reason
| 前端 | 服务端 | 求助记录 | 对面看到 |
|---|---|---|---|
| `leave("done")`(点「结束协作」) | `teardownRoom("done")` | 删除 | 协作已结束 |
| `leave("left")`(离开这道题的页面) | `teardownRoom("peer_left", side, ws)` | 教师走→**退回排队**;学生走→删除 | 老师暂时离开,已重新排队 |
`"left"` 复用的是老师掉线那条路(`requeueAfterTeacherGone`)—— 两件事语义相同。
**发起方收到的 reason 是 `done`**`teardownRoom``initiator` 参数),他不该看到
一句「对方离开了」。
配套:
- 协作中的「提交信息」改成新标签打开 —— 那是教师工具栏上唯一会跳路由的按钮,
而「看看这学生都交了什么」恰好是协作时最常点的一个;
- **学生排队期间离开那道题 → 自动取消求助**`SyncCodeEditor`
`cancelPendingIfLeaving`)。那条求助说的是「我卡在这道题」,人走了就不成立;
原来它会一直挂在队列里,老师接进来时学生的编辑器不在这道题上、根本不会绑,
老师对着空编辑器敲字,两边都没有提示。只认「离开」这件事、不跟房间状态挂钩:
老师走开时服务端刚补上的那条 pending 不能被它顺手取消掉;
- **协作中教师的语言选择跟着 `room.language` 走**(写 codeStore不只是改编辑器高亮
协作结束后还原成他自己那档。只改高亮的话,老师会拿着自己那档语言提交学生的代码
(学生写 C、老师选的是 Python当场 CE工具栏还可能显示「提交流程图」。
- 「重置代码」协作中不出现在菜单里:`v-model` 一写回就顺着 Yjs 同步过去,
等于一键清空学生的作业。
- 接单时 `resetScreenMode()`:分屏模式是「题目」或「自测」时右侧编辑器根本没挂出来,
停在同一道题上接单会落进一个没有编辑器的页面(跳到别的题时 `detail.vue`
`init()` 会重置,同页不会)。
- 老师已经在一个房间里时,前端也拦住接下一单(服务端本来就拦)—— 不拦的话前端
已经跳到新题目上,等于把手上那场协作断掉。
### 2026-09-16同日第三轮文案
`type: "error"` 的 message **会被前端原样弹成 toast**store 的 `case "error"`
`setNotice``message.info`),所以服务端那几条协议层错误从英文改成中文
`Invalid problemId` → 「题号不对,请刷新页面重试」等四条)。**以后往这个通道加
错误消息一律写中文、写成学生看得懂的话。**
「请先**退出**当前协作」改成「请先**结束**当前协作」,和按钮「结束协作」、前端那道
拦截统一成一个词。
`room_closed` 的提示语按 reason × 角色展开,原来四种情形共用一句「协作已结束」:
| reason | 谁收到 | 老师看到 | 学生看到 |
|---|---|---|---|
| `done` | 对面(有人点了「结束协作」) | 已结束这次协作 | 老师已结束这次帮忙 |
| `self_left` | **发起方**(自己离开了这道题) | 你已离开这道题,求助退回队列了 | 你已离开这道题,协作结束 |
| `peer_left` | 对面(对方离开了这道题) | 学生离开了这道题,协作已结束 | 老师暂时离开,你的求助已重新排队 |
| `peer_offline` | 对面(掉线 / 转发失败) | 对方已断开连接 | 对方已断开连接 |
`self_left` 是这一轮新加的:发起方原来跟对面收同一条 `done`,于是学生自己切走了
却看到「老师已结束这次帮忙」—— 一句假话。发起方收到的 reason 由 `teardownRoom`
`initiator` 决定。