docs(meeting): Task 16 E2E 剧本落盘 + .gitignore 精细化

Playwright MCP 4 场景剧本(.playwright-mcp/task16/):
- README.md:总览 + 账号/环境/资源断言指南
- scenarios/01-create-and-join-by-code.md:创建→会议号加入→媒体打通→结束,含 Redis/MySQL/media-server 三维资源断言
- scenarios/02-invite-link-with-password.md:邀请链接带密码 + P0-4 "密码不走 URL query" 回归
- scenarios/03-notify-quickjoin.md:通知中心「立即加入」跳转 + 过期卡片降级
- scenarios/04-host-toolkit-and-transfer.md:主持人四件套 + 宽限期重连/到期自动转让(P1-1 原子性 + P1-7 处理锁验证)
- scenarios/05-reopen-5times-resource-cleanup.md:反复开关 5 次 + 前端 heap 快照/ Redis 累积对比/ media-server 观测端点对比,验证 Step A(f5ae095)资源清理修复

手工回归点(manual-regression.md):17 项 MCP 难以稳定覆盖的交互场景
(HTTPS 自签 / 跨网段 iOS / 网络抖动 / 物理返回 / 跨 tab 并发 / 摄像头抢占 等)

.gitignore 调整:
- 放行 .playwright-mcp/task16/ 下的 Markdown 剧本文档进入版本控制
- 仍忽略运行时产物(*.log/*.png/*.yml/截图)
- task15/ 保持未跟踪状态,避免历史截图噪声入库

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-23 17:21:42 +08:00
parent f5ae095033
commit 5ed14c2aac
8 changed files with 737 additions and 1 deletions

15
.gitignore vendored
View File

@@ -61,7 +61,20 @@ coverage/
*.coverprofile
# --- Playwright 截图与 MCP 快照 ---
# 用法规则:.playwright-mcp 仅默认忽略"运行时产物"(截图/日志/page 快照 yml
# 通过精确白名单放行"剧本文档"(如 task16/ 下的 Markdown 回归剧本)进入版本控制。
# 注意gitignore 语义要求父目录不能整目录排除,否则子目录 ! 规则会失效
*.png
!design-system/**/*.png
.playwright-mcp/
# 默认忽略所有运行时产物
.playwright-mcp/*.log
.playwright-mcp/*.yml
.playwright-mcp/*.png
.playwright-mcp/*.txt
.playwright-mcp/*.jpg
!.playwright-mcp/.gitkeep
# Task 16 E2E 剧本文档task15 仍保持未跟踪,避免历史截图噪声)
.playwright-mcp/task16/**/screenshots/
.playwright-mcp/task16/**/*.log
.playwright-mcp/task16/**/*.png
.playwright-mcp/task16/**/*.yml

View File

@@ -0,0 +1,91 @@
# Task 16 Playwright MCP E2E 剧本
> 生成时间2026-04-23UTC+8
> 回归范围Phase 2e-2 Task 16 —— E2E 总回归 + 资源清理专项
> 环境macOS 25.3.0 / ChromiumPlaywright MCP/ 1440×900 桌面视口
> 三件套:`go-service :8085`、`media-server :3300`、`frontend :5173`HTTPS 自签)
## 场景矩阵
| # | 文件 | 覆盖设计文档章节 | 验证目标 | 资源断言 |
|---|---|---|---|---|
| 01 | [`scenarios/01-create-and-join-by-code.md`](scenarios/01-create-and-join-by-code.md) | §6.1 / §6.3 | 创建即时会议 → 会议号加入 → 双向音视频打通 → 主持人结束 | Router/Transport/Producer/Consumer 全量创建 + CloseRouter 级联释放 |
| 02 | [`scenarios/02-invite-link-with-password.md`](scenarios/02-invite-link-with-password.md) | §6.2 / §6.4 | 邀请链接带密码 → 受邀人免密加入 | `meeting_invites``consumed_at` 更新;密码不出现在 URL query |
| 03 | [`scenarios/03-notify-quickjoin.md`](scenarios/03-notify-quickjoin.md) | §6.4 | 通知中心「立即加入」按钮跳转 | `meeting_invitation` 通知类型 + `expired_at` 边界 |
| 04 | [`scenarios/04-host-toolkit-and-transfer.md`](scenarios/04-host-toolkit-and-transfer.md) | §6.5 / §6.6 | 主持人四件套(请静音/开麦/转让/踢出)+ host 宽限期自动转让 | `meeting_participants.role` 原子转让;`host_grace` Redis key + 处理锁 SETNX |
| 05 | [`scenarios/05-reopen-5times-resource-cleanup.md`](scenarios/05-reopen-5times-resource-cleanup.md) | §5.4 / §资源清理专项 | **反复开关 5 次会议,验证资源无泄漏** | `echo:meeting:resource:*` / `echo:meeting:member_state:*` / `echo:meeting:host_grace:*` / `echo:meeting:empty_ttl:*` 全部不残留;前端 `_engine` / `AudioContext` / `setInterval` 全部释放 |
## 测试账号
沿用 Task 15 账号体系(固定 `user_id` 便于断言):
| 账号 | 密码 | user_id | 角色 |
|---|---|---:|---|
| `task15_a` | `Task15@2026` | 55 | 主持人 Alice |
| `task15_b` | `Task15@2026` | 56 | 参会人 Bob |
| `task15_c` | `Task15@2026` | 57 | 参会人 Carol场景 04 需要第三人验证 host 宽限期转让目标) |
> 账号若不存在可用 `POST /api/v1/auth/register` 脚本化创建;密码策略要求大写 + 特殊字符。
## 通用前置条件
1. **三件套已就绪**
```bash
# backend/go-service
go run cmd/allinone/main.go
# media-server
cd media-server && npm run dev
# frontend
cd frontend && npm run dev:h5:https
```
2. **DB/Redis 干净**
```bash
# MySQL确认 meeting_rooms / meeting_participants / meeting_invites 无遗留 active 会话
mysql -e "UPDATE meeting_rooms SET status='ended' WHERE status='active'" echochat
# Redis清掉可能的残留
redis-cli --scan --pattern 'echo:meeting:*' | xargs -r redis-cli DEL
```
3. **浏览器**Chromium 允许 `localhost` 自签证书(`chrome://flags/#allow-insecure-localhost`),并授予 `camera` / `microphone` 权限。
4. **控制台期望**:无 `RTCPeerConnection error` / `NotReadableError` / `ResourceNotOwned`。
## 运行方式
每个剧本文件的 **"MCP 执行脚本"** 段落直接复制给 Playwright MCP AgentCursor Chat 中 `Use playwright-mcp` 触发):
```text
请按照 @.playwright-mcp/task16/scenarios/01-create-and-join-by-code.md 的 "MCP 执行脚本" 章节逐步执行并逐条断言。
```
Agent 会用 `browser_navigate` / `browser_click` / `browser_fill_form` / `browser_evaluate` 完成剧本,每步结果以截图 + 控制台日志 + Pinia state 快照形式落盘到 `.playwright-mcp/task16/<scene>/`。
## 资源断言脚本
每个场景结束后需要运行的资源检查(用于证明 Step A 的资源清理修复生效):
```bash
# Redis会议期间 key 清单(应仅含活跃会议,结束后应清零)
redis-cli KEYS 'echo:meeting:resource:*'
redis-cli KEYS 'echo:meeting:member_state:*'
redis-cli KEYS 'echo:meeting:host_grace:*'
redis-cli KEYS 'echo:meeting:empty_ttl:*'
# MySQL所有 active 会议
mysql -e "SELECT id, room_code, status, host_id FROM meeting_rooms WHERE status='active'" echochat
# media-server 观测端点Router / Transport / Producer / Consumer 汇总
curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats
```
## 产出物
每个场景执行后需要落盘:
- `scenarios/XX-*/screenshots/`:关键 UI 截图(入会前、打通后、结束后)
- `scenarios/XX-*/console.log`:浏览器控制台归档
- `scenarios/XX-*/result.md`:逐条断言结果(✅/❌ + 证据链接)
所有 5 场景跑完后在 `docs/reports/test-report-phase2e-2-meeting.md` 汇总。
## 手动回归点
Playwright MCP 难以覆盖的交互场景归档在 [`manual-regression.md`](manual-regression.md),由开发者手工过一遍并记录结论。

View File

@@ -0,0 +1,39 @@
# Task 16 手动回归点清单
> Playwright MCP 难以稳定覆盖的交互场景,由开发者手工在 HTTPS 环境过一遍并记录结论。
> 状态列:✅ 通过 / ❌ 失败 / ⏳ 待跑 / 不适用
| # | 场景 | 操作步骤 | 期望 | 状态 | 备注 |
|---|---|---|---|---|---|
| M1 | HTTPS 自签证书首次允许 | Chromium 打开 `https://localhost:5173/` → 点「高级」→「继续访问」→ 依次放行摄像头/麦克风 | uni-app 首页正常渲染;控制台无 Mixed Content 告警 | ⏳ | Playwright 多 context 需预注入证书信任 |
| M2 | iOS Safari 真机跨网段入会 | 手机浏览器Safari / 微信内置)访问 `https://<局域网 IP>:5173` → 扫码加入会议 | 音视频双向打通;`NetworkBadge` 显示 3 条绿波浪 | ⏳ | iOS Safari `srcObject` 行为与 Chromium 存在差异 |
| M3 | 网络抖动WS 断开 20s 后恢复 | 会议中开发者工具切到 Offline等 20s 后恢复 Online | 无声无画面约 5s 后自动 resume`remoteConsumers` 索引无漂移;不重复出现「其他用户」 | ⏳ | 关联 Task 14 `reconnect` 路径 |
| M4 | 网络抖动WS 断开 > 2 分钟 | Offline 维持 130s 后 Online | 被服务端视为宽限到期Alice主持人转让给 Bob本地 UI 跳回首页 | ⏳ | 场景 04 路径 B 的手工版 |
| M5 | 物理返回按钮离会 | 会议进行中按浏览器 / 手机物理返回 | `onUnload` 触发 `leave()` → REST `leaveRoom` 成功 → 本地资源释放 | ⏳ | 关注 `pages/meeting/room.vue` onUnload |
| M6 | 结束页停留久后返回 | 主持人结束 → 停在「会议已结束」提示页 5 分钟 → 再点返回 | `exitEndedRoom` 触发pinia state 重置;再次入会无残留 | ⏳ | **Step A F1 核心回归** |
| M7 | 跨 tab 并发入同一会议 | Alice tab 入会后复制 URL 到新 tab 再次打开 | 新 tab 命中"已在此会议中"保护;点提示按 `cleanupStaleMeetings` 自动清理后重试入会 | ⏳ | 关联 Task 11 `ErrAlreadyInMeeting` |
| M8 | 摄像头被系统抢占 | 入会后用其他应用抢占摄像头(如 FaceTime | UI 显示占位封面 + 「摄像头被占用」toast会议不崩溃释放后可重新开摄像头 | ⏳ | `NotReadableError` 分支 |
| M9 | 浏览器关闭/崩溃恢复 | 入会后直接 kill 浏览器进程,重启后重新登录并入同会 | 残留 participant 通过 `cleanupStaleMeetings` + 自动重试加入恢复 | ⏳ | 与 M7 互补 |
| M10 | Chat 面板 - 长消息发送 | 在会议中聊天面板发送 5000 字消息 | 服务端拒绝 `msg too long`(期望 P2 新增限制)或前端提前拦截 | ⏳ | 对应 P2-7 |
| M11 | 邀请 token 超时但会议仍存在 | 手工 SQL 将 `meeting_invites.expired_at` 改为过去时间 → 访问邀请链接 | 预览页显示「邀请已过期」,不允许加入;点「重新申请」可弹窗 | ⏳ | 对应 §6.4 |
| M12 | 主持人关闭摄像头但麦克风保留 | 会议中点视频开关 → 仅摄像头关闭 | 自己画面占位 + 工具栏视频按钮变灰;音频仍正常;其他成员成员面板图标同步 | ⏳ | 对应 Task 15 状态 |
| M13 | 静音氛围色 | Alice + Bob + Carol 全员关麦 | 工具栏背景切蓝色渐变;有任何一人开麦则复位 | ⏳ | Task 15 原创特色 4 |
| M14 | 说话者流光轮廓 | Bob 说话 ≥0.5s | Bob 视频 tile 出现流光边框;停 1s 后消失 | ⏳ | Task 15 原创特色 1 |
| M15 | 图钉 / 浮窗切换 | 桌面端点自视频浮窗的图钉图标 | 自视频从浮窗切回 2×2 宫格格子;再点一次返回浮窗 | ⏳ | Task 15 原创特色 3 |
| M16 | 无网情况下直接访问会议 | 断网后访问会议首页 | 友好错误提示Retry 可恢复 | ⏳ | 通用容错 |
| M17 | 高延迟网络150ms+ + 1% 丢包) | DevTools Throttling 选 `Slow 3G`;入会并说话 | 音视频可连通(降级);不崩溃;`NetworkBadge` 波浪数降为 1-2 | ⏳ | 生产近似网络 |
## 手工验证数据记录模板
每次跑完某一条,在 `docs/reports/test-report-phase2e-2-meeting.md` 填入:
```md
### M<编号> 验证记录
- 执行时间2026-04-xx HH:MM
- 环境Chromium 126 / macOS 25.3.0 / HTTPS 自签
- 结论:✅/❌
- 截图证据:`.playwright-mcp/task16/manual/M<编号>.png`
- 异常描述(若 ❌):...
- 修复动作:...
```

View File

@@ -0,0 +1,147 @@
# 场景 01创建会议 → 会议号加入 → 双向音视频打通 → 主持人结束
## 目标
- 验证 `POST /api/v1/meeting/rooms`(创建)+ `POST /api/v1/meeting/rooms/:code/join`(加入)+ mediasoup 信令全链路打通
- 覆盖设计 §6.1REST 建房、§6.3WS 信令 11 事件、§6.6NodeClient 媒体编排)
- 配套断言 Step A 资源清理:结束会议后 Redis / MySQL / media-server 全部归零
## 前置条件
依 [`../README.md`](../README.md) 通用前置;额外确认:
- 两个浏览器上下文MCP 多 tab分别以 `task15_a` / `task15_b` 登录
- Alice tab 为主持人Bob tab 为参会人
## MCP 执行脚本
### 步骤 1Alice 登录并创建即时会议
1. `browser_navigate``https://localhost:5173/#/pages/auth/login`
2. `browser_fill_form` → 账号 `task15_a` / 密码 `Task15@2026` → 点 "登录"
3. 断言首页进入「消息」tab
4. 底部切换到「会议」tab`pages/meeting/index`
5. 点「创建即时会议」
6. 设备预览页:默认勾选麦克风 + 摄像头;点「立即进入」
7. **进入 `pages/meeting/room`**,截图 → `01-alice-created.png`
8. 读取 URL query 得到 `roomCode`,记录到上下文变量 `$ROOM_CODE`
### 步骤 2Bob 通过会议号加入
1. 开第二个 browser context`browser_navigate``https://localhost:5173/#/pages/auth/login`
2.`task15_b` 登录
3. 切到「会议」tab点「通过会议号加入」
4. 输入 `$ROOM_CODE`
5. 预览页 → 「立即加入」
6. 截图 → `02-bob-joined.png`
### 步骤 3双向音视频打通断言
- Alice tab 成员面板应展示 2 个成员Bob 的 AV 图标为蓝色(开启状态)
- Bob tab 成员面板同理
- Alice 的视频宫格应能看到 Bob 的视频流(`VideoTile[user_id=56] video.srcObject` 非空)
- 控制台验证 mediasoup 关键日志:
- `[MediaEngine] producer 创建成功 kind=audio`
- `[MediaEngine] producer 创建成功 kind=video`
- `[MediaEngine] consumer 创建成功`
- `browser_evaluate`:
```js
const store = window.__pinia?.state?.value?.meeting
return {
participantCount: store.participants.length,
localAudio: store.localAudioEnabled,
localVideo: store.localVideoEnabled,
remoteUserCount: Object.keys(store.remoteConsumers).length,
}
```
期望:`participantCount=2`、`localAudio=true`、`localVideo=true`、`remoteUserCount=1`
### 步骤 4资源创建态断言
在第三个终端执行:
```bash
# media-server 应有 1 Router + 4 Transport2 users × send/recv+ 4 Producer + 4 Consumer
curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats
# Redis 应有两个用户的资源追踪 Set
redis-cli KEYS "echo:meeting:resource:$ROOM_CODE:*"
# 期望输出echo:meeting:resource:xxx:55 / echo:meeting:resource:xxx:56
# 每个 SMEMBERS 应有 5 条transport×2 + producer×2 + consumer×2 之类,具体取决于 producer.new 收敛顺序)
redis-cli SMEMBERS "echo:meeting:resource:$ROOM_CODE:55"
redis-cli SMEMBERS "echo:meeting:resource:$ROOM_CODE:56"
```
### 步骤 5Alice 结束会议
1. Alice tab 工具栏 → 「结束会议」→ 弹窗确认「主持人结束」
2. 截图 → `03-alice-ended.png`(页面转为「会议已结束」提示页)
3. Bob tab 应收到 WS `meeting.room.ended` 广播展示「会议已结束」toast1.2s 后跳回会议首页
4. 截图 → `04-bob-received-ended.png`
### 步骤 6资源释放断言核心验证 Step A
```bash
# 1. Redis 清理验证P1-4 + Step A B1/B3 的核心断言)
redis-cli KEYS "echo:meeting:resource:$ROOM_CODE:*" # 期望:(empty array)
redis-cli KEYS "echo:meeting:member_state:$ROOM_CODE:*" # 期望:(empty array)
redis-cli KEYS "echo:meeting:host_grace:$ROOM_CODE" # 期望:(nil)
redis-cli KEYS "echo:meeting:empty_ttl:$ROOM_CODE" # 期望:(nil)
# 2. MySQL 状态验证
mysql -e "SELECT status, ended_reason FROM meeting_rooms WHERE room_code='$ROOM_CODE'" echochat
# 期望status=ended, ended_reason=host_ended
mysql -e "SELECT user_id, left_at, left_reason FROM meeting_participants WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat
# 期望Alice + Bob 都有 left_at ≠ NULL, left_reason='host_end'
# 3. media-server Router / Transport / Producer / Consumer 全部归零
curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq '.stats | .routers, .transports, .producers, .consumers'
# 期望:对应 $ROOM_CODE 的 Router 已销毁;整体 stats 可能非 0其他并行测试但本场 Router 必须不存在
```
### 步骤 7前端资源释放断言
在 Alice tab 执行 `browser_evaluate`:
```js
// 结束页停留时的 state 快照
const store = window.__pinia?.state?.value?.meeting
return {
localState: store.localState, // 期望:'ended'
engineExists: !!window.__engine, // 期望false_cleanupMedia 已置空)
participantCount: store.participants.length, // 结束页保留渲染数据,非 0
}
```
然后手动返回首页(模拟 `onUnload` 触发):
1. `browser_navigate_back` 或点结束页的「返回首页」按钮
2. 再次 `browser_evaluate` 断言 pinia state
```js
const store = window.__pinia?.state?.value?.meeting
return {
localState: store.localState, // 期望:'idle'
participantCount: store.participants.length, // 期望0
currentRoom: store.currentRoom, // 期望null
}
```
## 预期结果汇总
| 断言维度 | 预期 |
|---|---|
| WS 信令 | `room.join` / `producer.new` / `consume` 全链路无异常 |
| 双向媒体 | 两端视频宫格均能播放对方画面,`localAudioEnabled=true` 且 `remoteConsumers` 含对方 |
| Redis 清理 | `resource:*` / `member_state:*` / `host_grace` / `empty_ttl` 全空 |
| DB 一致 | `meeting_rooms.status=ended`、两行 `meeting_participants.left_at` 非空 |
| media-server | 对应 Router 已删除level transport/producer/consumer 观察端点无残留 |
| 前端 | `_engine=null` / `_pendingBroadcastTimers.size=0``onUnload` 后 pinia state 全部 reset |
## 失败排查
| 现象 | 可能原因 | 排查点 |
|---|---|---|
| Bob 看不到 Alice 的画面 | `pushExistingRoomState` 同步性Task 16 P1-8 修复) | `logs.Info` 搜 `room.join` 是否先于 `member.joined` |
| 结束后 Redis `resource:*` 仍存 | `EndRoom` 未走 `cleanupRoomRedisResidual`Step A B1 回归) | `grep "cleanupRoomRedisResidual" backend/go-service/app/meeting/service/meeting_service.go` |
| `localState` 卡在 `connected` | `_onRoomEnded` 未触发 | 检查 Bob tab 是否收到 `meeting.room.ended` WS 事件(网络面板 frame |

View File

@@ -0,0 +1,91 @@
# 场景 02邀请链接加入带密码
## 目标
- 验证 `POST /api/v1/meeting/rooms/:code/invites`(签发邀请)+ `POST /api/v1/meeting/invites/:token/consume`(消费邀请)
- 覆盖设计 §6.2(邀请 token 7 天有效期、§6.4(邀请链接 URL
- **验证 Task 16 P0-4 修复**:密码不出现在 URL query仅通过内存 state 或 Sec-Fetch 头传递
## 前置条件
依 [`../README.md`](../README.md) 通用前置。
## MCP 执行脚本
### 步骤 1Alice 创建带密码的会议
1.`task15_a` 登录
2. 会议首页 → 「创建会议(自定义)」→ 切换到有密码模式
3. 填充:
- 会议主题:`Task16 邀请回归`
- 密码:`invite@2026`
- 其他留默认
4. 点「创建并进入」
5. 记录 `$ROOM_CODE`
### 步骤 2Alice 生成邀请链接
1. 工具栏 → 「邀请」 → 「复制邀请链接」
2. 从剪贴板读取链接;格式示例:`https://localhost:5173/#/pages/meeting/preview?mode=join&code=xxx&invite_token=yyy`
3. **断言 URL query 不含 password 明文**P0-4 核心断言):
```js
// browser_evaluate
const link = await navigator.clipboard.readText()
return { link, hasPassword: /password=/i.test(link) }
```
期望:`hasPassword=false`
### 步骤 3Bob 通过邀请链接加入
1. 开 Bob tab已登录 `task15_b`
2. `browser_navigate` → 步骤 2 拿到的链接
3. 预览页展示「即将加入 `$ROOM_CODE` / 邀请人 Alice」+ 设备选择面板
4. 点「立即加入」
5. 期望:**无需输入密码**(邀请 token 已授权)
6. 截图 → `02-bob-joined-via-invite.png`
### 步骤 4资源与 DB 断言
```bash
# meeting_invites.consumed_at 应被填充
mysql -e "SELECT invite_token, invitee_id, consumed_at FROM meeting_invites \
WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat
# meeting_participants 应有 Bob 行joined_method='invite'
mysql -e "SELECT user_id, joined_method, invite_token FROM meeting_participants \
WHERE room_id=(SELECT id FROM meeting_rooms WHERE room_code='$ROOM_CODE')" echochat
```
期望:
- `consumed_at` 时间戳为最近几秒
- Bob 行 `joined_method='invite'``invite_token` 与邀请链接一致
### 步骤 5直接输入会议号不含邀请 token应要求密码
1. 第三个 tab 以 `task15_c` 登录
2. 会议首页 → 「通过会议号加入」 → 输入 `$ROOM_CODE`
3. 预览页 / 加入接口应返回 `ErrPasswordRequired` → UI 弹出密码输入框
4. 输入错误密码 `wrong`,断言返回 401 + UI 提示
5. 输入正确密码 `invite@2026`,成功加入
6. 截图 → `03-carol-password-join.png`
### 步骤 6资源清理场景结束
Alice 结束会议;执行 [`01-create-and-join-by-code.md`](01-create-and-join-by-code.md) 步骤 6-7 的 Redis/DB 断言。
## 预期结果汇总
| 断言 | 预期 |
|---|---|
| 邀请 URL 不含 password | `hasPassword=false` |
| Bob 免密通过邀请加入 | HTTP 200 + WS `room.join` ACK |
| `meeting_invites.consumed_at` | 时间戳 ≈ 加入瞬间 |
| Carol 直接加入需要密码 | 401 → 输入后成功 |
## 失败排查
| 现象 | 定位 |
|---|---|
| URL 仍含 `password=` | P0-4 回归:`frontend/src/store/meeting.js` `draftJoinPayload` 是否将密码存内存 |
| Bob 使用邀请仍被要求密码 | `meeting_service.JoinRoom` 的 invite token 分支 |
| `consumed_at` 仍为 NULL | `MeetingInviteDAO.Consume` 未调 / 事务回滚 |

View File

@@ -0,0 +1,86 @@
# 场景 03通知中心「立即加入」按钮跳转
## 目标
- 验证邀请被签发时,受邀人通知中心实时刷新 + 卡片双按钮(「立即加入」/「稍后」)
- 覆盖设计 §6.4(邀请通知类型 `meeting_invitation`
- 过期卡片应渲染为 disabled 单按钮「邀请已过期」
## 前置条件
依 [`../README.md`](../README.md) 通用前置Alice 已在会议中(复用场景 02 的 `$ROOM_CODE`),或新开一场会议。
## MCP 执行脚本
### 步骤 1Alice 邀请 Bob
1. Alice tab 工具栏 → 「邀请」 → 「发送给联系人」
2. 联系人弹窗选择 Bob → 确认
3. 期望UI toast「邀请已送达」关闭弹窗
### 步骤 2Bob 通知中心实时接收
1. Bob tab已登录 `task15_b`)→ 底部切到「通知」tab
2. 期望:最顶部新增卡片
- 类型标识:`会议邀请`
- 内容:`Alice 邀请你加入会议 $ROOM_CODE`
- 双按钮:**「立即加入」 + 「稍后」**
3. 截图 → `01-notify-new-invite.png`
4. `browser_evaluate` 断言:
```js
const store = window.__pinia?.state?.value?.notify
const latest = store.list[0]
return {
type: latest.notify_type, // 期望:'meeting_invitation'
expired: latest.expired_at < Date.now()/1000, // 期望false
hasButtons: !!latest.ext?.room_code // 期望true
}
```
### 步骤 3点击「立即加入」跳转
1. 点「立即加入」
2. 期望跳转 `pages/meeting/preview?mode=join&code=$ROOM_CODE&invite_token=...`
3. 预览页正常展示
4. 点「立即加入」完成入会
5. 截图 → `02-bob-joined-from-notify.png`
### 步骤 4过期邀请回归
手工构造过期通知(通过 DB 或 gorm script
```sql
-- 插入一条 Bob 收到的过期邀请通知expired_at=1 天前)
INSERT INTO notifications (user_id, notify_type, title, content, ext, expired_at, created_at)
VALUES (56, 'meeting_invitation', '会议邀请',
'Alice 邀请你加入会议 000-000-000',
'{"room_code":"000-000-000","invite_token":"expired"}',
UNIX_TIMESTAMP() - 86400, NOW());
```
1. Bob tab 刷新通知 tab
2. 期望:该卡片渲染为 **disabled 单按钮「邀请已过期」**
3. 尝试点击应无跳转
4. 截图 → `03-notify-expired-invite.png`
### 步骤 5清理
1. SQL 删除步骤 4 插入的测试通知
2. 结束会议(若 Alice 仍在会中)
## 预期结果汇总
| 断言 | 预期 |
|---|---|
| 通知中心实时刷新 | 邀请送达后 3s 内出现新卡片 |
| 卡片双按钮 | 「立即加入」 + 「稍后」 |
| 「立即加入」跳转 | `preview?mode=join&code=xxx&invite_token=yyy` |
| 过期卡片 | 单按钮 disabled「邀请已过期」 |
## 失败排查
| 现象 | 定位 |
|---|---|
| Bob 通知 tab 空 | WS `notify.new` 事件未收到 / notify store 未订阅 |
| 「立即加入」点击无反应 | `components/notify/NotifyCard.vue` 按钮 handler |
| 过期卡片仍 clickable | NotifyCard 的 `isExpired` 判定逻辑 |

View File

@@ -0,0 +1,139 @@
# 场景 04主持人四件套 + 宽限期自动转让
## 目标
- **验证 Task 15 主持人四件套**:请他静音、请他开麦、转让主持人、踢出会议
- **验证 Task 8 host 宽限期状态机**P1-1 原子转让 + P1-7 处理锁):
- host 主动断开 WS → 宽限期内重连 → 撤销转让
- host 宽限期到期 → 自动转让给最早加入的参会人
- 覆盖设计 §6.5生命周期状态机、§6.6(权限矩阵)
## 前置条件
依 [`../README.md`](../README.md) 通用前置;**需 3 个账号 Alice/Bob/Carol 同时入会**。
## MCP 执行脚本 A主持人四件套
### 步骤 1三人入会
1. Alice 创建即时会议 → `$ROOM_CODE`
2. Bob 用会议号加入
3. Carol 用会议号加入
4. 截图 → `01-three-joined.png`
### 步骤 2Alice 请他静音 Bob
1. Alice tab 打开成员面板 → Bob 右侧 「⋯」 菜单 → 「请他静音」
2. Bob tab 期望:
- Toast「主持人已将你的麦克风关闭」
- `localAudioEnabled=false`
- 成员面板自己的 AV 图标变灰
3. Alice tab 期望Bob 行 AV 图标变灰
4. 截图 → `02-bob-muted-by-host.png`
### 步骤 3Alice 请他开麦 Bob
1. Alice tab → Bob 「⋯」菜单 → 「请他开麦」
2. Bob tab 期望:
- 弹窗请求确认「主持人请你开启麦克风,是否同意?」
- 点「同意」后 `localAudioEnabled=true`
3. 截图 → `03-bob-ask-unmute.png`
### 步骤 4Alice 踢出 Carol
1. Alice tab → Carol 「⋯」菜单 → 「踢出会议」
2. 弹窗确认「踢出 Carol」→ 确认
3. Carol tab 期望:
- 收到 `meeting.member.kicked` 广播
- Toast「主持人已将你移出会议」
- 跳回会议首页
4. Alice / Bob tab 期望Carol 行从成员列表消失
5. 截图 → `04-carol-kicked.png`
### 步骤 5Alice 转让主持人给 Bob
1. Alice tab → Bob 「⋯」菜单 → 「转让主持人」
2. 弹窗确认 → 确认
3. **原子性验证**P1-1
```bash
mysql -e "SELECT host_id, (SELECT role FROM meeting_participants WHERE room_id=r.id AND user_id=55) AS alice_role, \
(SELECT role FROM meeting_participants WHERE room_id=r.id AND user_id=56) AS bob_role \
FROM meeting_rooms r WHERE room_code='$ROOM_CODE'" echochat
```
期望同时满足:`host_id=56` + `alice_role=participant` + `bob_role=host`(同一事务原子更新)
4. Alice tab / Bob tab 工具栏切换Alice 看到"结束会议"按钮消失Bob 看到它出现)
5. 截图 → `05-host-transferred.png`
## MCP 执行脚本 B宽限期自动转让
### 前置:接续场景 A 的会议;此时 Bob 是主持人Alice 是参会人
### 步骤 6Bob 断开 WS模拟网络断开
1. Bob tab DevTools → Network 面板 → Throttling 切换到 **Offline**
2. 等待 WS 心跳超时(~15sBob 客户端 `_onWsDisconnected` 触发
3. 期望 Redis 立即写入 `echo:meeting:host_grace:$ROOM_CODE`
```bash
redis-cli GET "echo:meeting:host_grace:$ROOM_CODE"
# 期望JSON {host_id:56, started_at:..., grace_until:...}
redis-cli TTL "echo:meeting:host_grace:$ROOM_CODE"
# 期望:约 120 秒(配置 `meeting.host_grace_seconds`
```
4. Alice tab 期望:工具栏显示「主持人掉线中(宽限期 2:00」倒计时若 UI 已实现)
### 步骤 7a宽限期内重连撤销转让
1. 15 秒后 Bob tab Network 恢复 Online
2. 期望 `_onWsReconnected` 触发,执行 `OnHostReconnect`
```bash
redis-cli EXISTS "echo:meeting:host_grace:$ROOM_CODE"
# 期望0key 已 DEL
```
3. Alice tab 工具栏倒计时消失
4. 查日志:
```bash
tail -50 backend/go-service/logs/*.log | grep "host 宽限期内重连"
# 期望:`host 宽限期内重连,撤销转让计划`
```
### 步骤 7b宽限期到期自动转让
**若步骤 7a 已执行,请先让 Bob 重新 Offline 并等待整个 120s 周期完成。**
1. Bob tab Offline保持 120+s
2. 期望本地 timer 到期触发 `HandleHostGraceExpired`
- Alice tab 收到 `meeting.host.changed` 广播,`auto_reason='host_grace_expired'`
- 工具栏/成员面板 Alice 头像变为主持人标识
- `meeting_rooms.host_id=55` + `meeting_participants.role` 原子变更
3. **处理锁验证P1-7**
```bash
# 宽限期到期瞬间抓一下处理锁
redis-cli GET "echo:meeting:host_grace_handling:$ROOM_CODE"
# 期望:`1`SETNX 成功 60s TTL60s 后自然过期
```
4. 截图 → `06-host-auto-transferred.png`
### 步骤 8清理
Alice现任主持人结束会议走场景 01 的资源清理断言。
## 预期结果汇总
| 断言 | 预期 |
|---|---|
| 请他静音 | Bob `localAudioEnabled=false` + toast |
| 请他开麦 | Bob 弹窗确认后 `localAudioEnabled=true` |
| 踢出 | Carol 跳首页 + 成员列表移除 |
| 主动转让原子性 | `host_id` + 两行 `role` 同事务更新 |
| host 宽限期 Redis | key 写入 + TTL≈120s |
| 宽限内重连 | key DEL + 本地 timer cancel |
| 宽限到期自动转让 | 最早加入者成为新 host + 广播 `host.changed` |
| 处理锁 SETNX | `host_grace_handling:*` 存在 60s |
## 失败排查
| 现象 | 定位 |
|---|---|
| 转让后 `host_id` 和 `role` 不一致 | P1-1 回归:检查 `TransferHost` 是否包在单事务 |
| 宽限到期但 host 不变 | 看 handling 锁是否被"自然过期 + DEL=0"误拦P1-7 应已修) |
| 多节点部署下双重转让 | SETNX 处理锁应保证单节点唯一处理 |

View File

@@ -0,0 +1,130 @@
# 场景 05反复开关 5 次会议 —— 资源清理专项回归
## 目标
- **端到端验证 Step A 资源清理修复(提交 `f5ae095`**
- 前端 `_engine` / `AudioContext` / `setInterval` / `_pendingBroadcastTimers` 在每次结束会议后完全释放
- 后端 Redis `resource:*` / `member_state:*` / `host_grace:*` / `empty_ttl:*` 零累积
- mediasoup Router / Transport / Producer / Consumer 不残留
- 回归 B1 / B2 / B3 三条资源清理路径
- 资源断言曲线5 轮循环后 Redis `dbsize` 与 mediasoup `/internal/info` 恢复到 baseline
## 前置条件
依 [`../README.md`](../README.md) 通用前置;额外确认:
- 单浏览器 context 双 tabAlice + Bob
- 开启 Chromium DevTools → Memory → **勾选「Record allocation stacks」**,在脚本执行前 + 第 5 轮结束后各拍一次 heap snapshot
## MCP 执行脚本
### 步骤 0baseline 采集
```bash
# baseline_redis.txt
redis-cli DBSIZE | tee /tmp/baseline_redis.txt
redis-cli KEYS 'echo:meeting:*' | wc -l >> /tmp/baseline_redis.txt
# baseline_media.json
curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info \
| jq .stats > /tmp/baseline_media.json
```
在 Alice tab 的 DevTools Performance 面板录制整段脚本;脚本起止时刻各打一个 `performance.mark`
### 步骤 1-5循环 5 次创建→打通→结束
`i in {1..5}`
1. **创建**Alice tab 会议首页 → 创建即时会议 → 设备预览 → 立即进入(记录 `$ROOM_CODE_i`
2. **加入**Bob tab 通过会议号加入 `$ROOM_CODE_i`
3. **打通断言**(轻量):两端 `browser_evaluate` 检查 `localAudioEnabled && localVideoEnabled`
4. **结束**Alice 点「结束会议」Bob tab 等待 `room.ended` toast
5. **结束后清理断言**(每轮都要做):
```bash
echo "===== Round $i ====="
redis-cli KEYS "echo:meeting:resource:$ROOM_CODE_i:*" # 必须为空
redis-cli KEYS "echo:meeting:member_state:$ROOM_CODE_i:*" # 必须为空
redis-cli KEYS "echo:meeting:host_grace:$ROOM_CODE_i" # 必须 nil
redis-cli KEYS "echo:meeting:empty_ttl:$ROOM_CODE_i" # 必须 nil
```
6. **前端资源断言**(每轮):
```js
// 在 Alice tab 的结束页执行
const store = window.__pinia?.state?.value?.meeting
return {
round: $i,
localState: store.localState, // 'ended'
pendingBroadcastTimers: window.__broadcastTimersSize ?? 'N/A',
}
```
7. **回首页**Alice tab 物理返回(`browser_press_key` Back / 点击结束页「返回」按钮)触发 `onUnload` → `exitEndedRoom`
8. `browser_evaluate` 再次确认 pinia state 全 reset`localState='idle' && participantCount=0 && !engineExists`
9. Bob tab 同样回首页
10. **延迟 2 秒**模拟用户操作间隙,进入下一轮
### 步骤 65 轮结束后的累积断言(核心)
```bash
# 对比 baseline必须零增量
echo "===== Redis 累积对比 ====="
redis-cli DBSIZE
redis-cli KEYS 'echo:meeting:*' | wc -l
diff /tmp/baseline_redis.txt <(redis-cli DBSIZE && redis-cli KEYS 'echo:meeting:*' | wc -l)
echo "===== media-server 累积对比 ====="
curl -s -H "X-Internal-Token: $INTERNAL_TOKEN" http://localhost:3300/internal/info | jq .stats > /tmp/after5_media.json
diff /tmp/baseline_media.json /tmp/after5_media.json
echo "===== MySQL 快照 ====="
mysql -e "SELECT COUNT(*) AS active FROM meeting_rooms WHERE status='active'" echochat
# 期望active=0
mysql -e "SELECT COUNT(*) AS alive FROM meeting_participants WHERE left_at IS NULL" echochat
# 期望alive=0
```
### 步骤 7前端内存快照对比
1. 在 Alice tab 打开 DevTools → Memory → 拍 heap snapshot #25 轮结束后)
2. 对比 #1baseline与 #2
- 关键类:`MediaStream` / `RTCPeerConnection` / `AudioContext` / `Consumer` / `Producer` 的对象数量
- 允许少量增长mediasoup-client `Device` 缓存),但必须远小于 5×
3. 记录 Performance tab 的「JS Heap」曲线应呈锯齿下降每次 GC 回收),**不得持续单调上升**
## 预期结果汇总
| 断言维度 | 期望 |
|---|---|
| 每轮 Redis 清理 | `resource/member_state/host_grace/empty_ttl` 全空 |
| 5 轮累积 Redis `DBSIZE` | 与 baseline 一致(允许 ±2 容错,针对 session/heartbeat key |
| 5 轮累积 media-server | Router / Transport / Producer / Consumer 回到 baseline |
| MySQL | `active` 会议数 = 0`left_at IS NULL` 参与者 = 0 |
| 前端 heap snapshot | 关键对象类数量增长 ≤ 2近似常数非线性 |
| 前端 Performance | JS Heap 曲线呈锯齿,无持续上升趋势 |
| 控制台 | 无 `unhandled promise rejection` / `NotReadableError` / `leaked listener` |
## 资源清理验证矩阵(对应 Step A 修复)
| 代码路径 | 验证手段 | 通过条件 |
|---|---|---|
| F1 `_onRoomEnded` 结束页数据保留 | `localState='ended'` 时 `currentRoom` 非 null | 结束页正确渲染 |
| F1 `exitEndedRoom` 动作 | onUnload 后 `currentRoom=null` | pinia state reset |
| F2 `endMeeting` 本地兜底 | 断开 WS 再结束(额外场景) | 本地仍能触发 `_onRoomEnded` |
| F3 `_pendingBroadcastTimers` | 结束后 `clearTimeout` | `window.__broadcastTimersSize=0` |
| B1 `cleanupRoomRedisResidual` | 每轮 Redis 查 | `resource:*` / `member_state:*` = 0 |
| B3 `OnRoomEnded` timer 取消 | pprof / sync.Map 大小 | `graceTimers` / `emptyTTLTimers` 稳定无增长 |
## 失败排查
| 现象 | 定位 |
|---|---|
| 某轮 `resource:*` 非空 | `EndRoom` 未走 cleanup → 检查 commit `f5ae095` 是否正确落地 |
| Redis `DBSIZE` 持续增长 | 检查 session/heartbeat key 是否泄漏;排除 meeting 模块 |
| 前端 heap 持续上升 | 抓 heap diff找保留路径常见`Producer._events`、`Vue reactive` 持有 Consumer |
| `localState` 卡 `ended` 不回 `idle` | `onUnload` 未调 `exitEndedRoom` 或 `room.vue` 组件未卸载(检查 H5 `uni.reLaunch` 行为) |
## 延伸测试(可选)
- **10 轮**:循环次数改 10观察是否线性无增长
- **跨 tab 并发**:第 3 轮时 Bob 主动 leave而非等 Alice end验证双路径清理一致
- **主动断网**:第 4 轮打通后 Alice 关网卡 2 分钟 → 触发 host grace → 进入场景 04 路径