feat(phase2e-2): 落地会议生命周期状态机 + Router 幂等双层防御(Task 8)
核心交付:
- 新建 MeetingLifecycleService(6 钩子 + sync.Map 本地 timer + Redis key 双保险 + RescheduleFromRedis)
- 新建 MeetingCleanupTask(启动重建 timer + 每 N 秒扫 host_grace/empty_ttl 兜底 + 4h stale active 回收)
- MediaOrchestrator 新增 ResolveRouterID;HTTPMediaOrchestrator.CreateRouter 入口 sync.Map 幂等防御
- 业务层 JoinRoom 移除 CreateRouter 调用改走 CancelEmptyTTL + ResolveRouterID;LeaveRoom 空房分支改调 OnAllMembersLeft 不再立即销毁
- MeetingSignalService 新增 OnWSDisconnect 实现 ws.MeetingDisconnectHook;OnRoomJoin 追加 host 重连钩子
- ws.handler 定义 MeetingDisconnectHook 接口 + SetMeetingDisconnectHook,解耦 ws→meeting 反向依赖
- config 新增 MeetingConfig{HostGrace=120, EmptyRoomTTL=300, CleanupInterval=30, StaleRoomHours=4}
关键设计决策:
- Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer:避免本地 timer 与
Redis 自动过期同步到期导致 DEL 返回 0 被误判为"已被其他路径处理"而跳过业务逻辑
- Router 幂等双层防御(决策 q2_router_dedup=a2_both):业务层不重复调 + HTTP 层 sync.Map 命中直接返回
- 普通成员 WS 断开仅清 media 资源不动 participant 表(决策 q1_nonhost_disconnect=a1_keep_current)
E2E 验证:docs/verify/meeting_t8_verify.mjs PASS=20 FAIL=0,覆盖 5 场景:
- S1 host 宽限期过期自动转让(meeting.host.changed + DB host_id 更新)
- S2 宽限期内重连保留身份
- S3 empty_ttl 期内新成员加入复活房间
- S4 empty_ttl 过期 → 房间 Ended + 新 join 被拒
- S5 CreateRoom +1 Router / JoinRoom 不再创建新 Router(通过 media-server /internal/info stats.routers 断言)
media-server:/internal/info 响应追加 stats.routers + routers[] 供 E2E 断言 Router 幂等
文档同步:
- docs/progress/CURRENT_STATUS.md 头部 + 新增 Task 8 交付条目
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 8 标记完成 + 实际产出/决策/验证
- docs/api/frontend/meeting.md 补充 host.changed.auto_reason / room.ended.reason=system_error / 空房 TTL 复活语义 + Task 8 验证记录
- docs/architecture/system-architecture.md meeting 模块职责补充"会议生命周期状态机"
- .cursor/rules/project-context.mdc 追加 Task 8 条目并更新 Phase 2e-2 进度(Task 0-8 ✅)
Made-with: Cursor
This commit is contained in:
@@ -208,8 +208,8 @@
|
||||
|
||||
**行为**:
|
||||
- `participant.left_at = now`、`duration = EXTRACT(EPOCH FROM now - joined_at)`;
|
||||
- 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给**最早加入的活跃成员**,广播 `meeting.host.changed`;
|
||||
- 若房间无剩余活跃成员 → 标记 `status=2 ended_reason=empty_ttl` 并触发 mediaOrchestrator.CloseRouter;
|
||||
- 若离开者是 host 且仍有其他活跃成员 → 自动将 host 转让给**最早加入的活跃成员**,广播 `meeting.host.changed`(`auto_reason=host_left_with_members`);
|
||||
- **若房间无剩余活跃成员**(Task 8 变更)→ **不再立即销毁**,改为调 `MeetingLifecycleService.OnAllMembersLeft` 设置 `echo:meeting:empty_ttl:{code}`(默认 TTL 300s)+ 启动本地 `time.AfterFunc`;TTL 内若有新成员 `POST /join`,`MeetingLifecycleService.CancelEmptyTTL` 会 DEL key 让房间复活;TTL 过期触发 `HandleEmptyRoomExpired` → `MarkEnded(reason=empty_ttl)` + `mediaOrchestrator.CloseRouter` + 广播 `meeting.room.ended{reason=empty_ttl}`;
|
||||
- 广播 `meeting.member.left`。
|
||||
|
||||
**响应 `200 OK`**
|
||||
@@ -595,8 +595,8 @@
|
||||
| `meeting.member.joined` | room_code / participant 对象 | REST /join 触发 |
|
||||
| `meeting.member.left` | room_code / user_id / reason | REST /leave /kick 或 WS 资源清理 |
|
||||
| `meeting.member.kicked` | room_code / reason | 仅发给被踢者本人,对应 REST /kick |
|
||||
| `meeting.host.changed` | room_code / old_host_id / new_host_id | REST /transfer-host 或 host 离会自动转让 |
|
||||
| `meeting.room.ended` | room_code / reason(`host_ended` / `empty_ttl`) | REST /end 或空房 TTL |
|
||||
| `meeting.host.changed` | room_code / old_host_id / new_host_id / **auto_reason**? | REST /transfer-host(无 `auto_reason`)/ host 离会自动转让(`auto_reason=host_left_with_members`)/ **host 宽限期过期自动转让**(`auto_reason=host_grace_expired`,Task 8 新增) |
|
||||
| `meeting.room.ended` | room_code / reason(`host_ended` / `empty_ttl` / **`system_error`**) | REST /end(`host_ended`)/ 空房 TTL 过期(`empty_ttl`)/ 兜底清理 4 小时陈旧房间(`system_error`,Task 8 新增) |
|
||||
| `meeting.member.state.changed` | 见白名单 #3 | WS `meeting.member.state.changed` 触发 |
|
||||
| `meeting.member.producer.new` | 见白名单 #6 / #8 | WS `meeting.produce.start` / `meeting.producer.close` 触发 |
|
||||
| `meeting.chat` | message 对象 | REST /chats 触发 |
|
||||
@@ -639,11 +639,16 @@
|
||||
- `meeting.transport.create`(direction=send)返回**真实** mediasoup `id`(非 `noop-` 前缀)+ `iceParameters` 对象 + `iceCandidates[]` 非空 + `dtlsParameters.fingerprints[]` 非空
|
||||
- 虚构 producer_id 调 `meeting.producer.close` → Node 返回 404 → Go 幂等转 `code=0`
|
||||
- host `POST /rooms/:code/end` 触发 `CloseRouter`,media-server 日志确认 `router closed explicitly`
|
||||
- **Task 8** 端到端验证脚本(`docs/verify/meeting_t8_verify.mjs`)结果:**20/20 PASS**,覆盖 5 个会议生命周期场景:
|
||||
- **S1 host 宽限期过期自动转让**:host WS 断线 → Redis 写入 `echo:meeting:host_grace:{code}` → 3s 后过期 → `meeting.host.changed{auto_reason=host_grace_expired}` 广播 + DB `host_id` 更新为最早加入者
|
||||
- **S2 宽限期内重连保留身份**:host 断线 1s 内重连 `meeting.room.join` → `host_grace` key DEL + host 身份保留
|
||||
- **S3 空房 TTL 复活**:全员 leave → Redis 写入 `echo:meeting:empty_ttl:{code}` → TTL 内新用户 join → key DEL + 房间保持 Active
|
||||
- **S4 空房 TTL 过期销毁**:全员 leave → 3s 后 TTL 过期 → 房间 Ended + 新 join 被拒
|
||||
- **S5 Router 幂等**:`POST /meeting/rooms` 后 `stats.routers` +1;`POST /meeting/rooms/:code/join` 不再触发新 Router(业务层 `JoinRoom` 不调 `CreateRouter` + HTTP 层 `sync.Map` 幂等防御)
|
||||
|
||||
---
|
||||
|
||||
## 后续任务关联
|
||||
|
||||
- **Task 8**:会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL),同时修复"CreateRoom + JoinRoom 重复调 `CreateRouter`"遗留行为。
|
||||
- **Task 9**:Vue 前端 mediasoup-client 接入,按本文 WS 契约实现 `mediasoup.Transport` 的 `connect / produce` 回调;补齐 `ResumeConsumer`(Node REST 已就绪)或在 Go 侧改为创建 Consumer 后自动 resume。
|
||||
- **Task 9**:Vue 前端 mediasoup-client 接入,按本文 WS 契约实现 `mediasoup.Transport` 的 `connect / produce` 回调;补齐 `ResumeConsumer`(Node REST 已就绪)或在 Go 侧改为创建 Consumer 后自动 resume;需处理 `meeting.host.changed` 的 `auto_reason` 字段以在 UI 上标注"自动转让"。
|
||||
- **Task 13**:通知卡片 UI 补齐 `meeting_invite` 内联按钮。
|
||||
|
||||
@@ -94,7 +94,7 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想
|
||||
| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b |
|
||||
| group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | ✅ Phase 2c |
|
||||
| file | 文件上传(MinIO 对象存储 + 通用上传 API) | ✅ Phase 2c |
|
||||
| meeting | 会议创建/管理、WebSocket 信令转发、mediasoup 资源编排(Go→Node HTTP)、会议内聊天、主持人控制 | 🚧 Phase 2e-2 代码开发中(Task 0-7 ✅:12 REST + 13 WS 事件 + HTTPMediaOrchestrator 真实 Router/Transport/Producer/Consumer 落地;Task 8-16 待执行);专用设计:`docs/plans/2026-04-21-phase2e-2-design.md` |
|
||||
| meeting | 会议创建/管理、WebSocket 信令转发、mediasoup 资源编排(Go→Node HTTP)、**会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL)**、会议内聊天、主持人控制 | 🚧 Phase 2e-2 代码开发中(Task 0-8 ✅:12 REST + 13 WS 事件 + HTTPMediaOrchestrator 真实媒体链路 + MeetingLifecycleService + MeetingCleanupTask + Router 幂等双层防御;Task 9-16 待执行);专用设计:`docs/plans/2026-04-21-phase2e-2-design.md` |
|
||||
| notify | 统一通知中心、好友/群聊事件 + 系统广播推送、11 种类型枚举、跨模块 Pusher 接口注入 | ✅ Phase 2e-1(10 种落地 + 2 种预留 `meeting_invite` / `meeting_reminder`) |
|
||||
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
|
||||
> **分支:** `feature/phase2e-2-meeting-mvp`
|
||||
> **预估总工时:** **约 17 人日**(17 个 Task,含 PoC 与 UI 打磨)
|
||||
> **最后更新:** 2026-04-21(Task 0-7 ✅ 已落地,Go↔Node HTTP 媒体链路打通,下一步 Task 8 生命周期状态机)
|
||||
> **最后更新:** 2026-04-21(Task 0-8 ✅ 已落地,会议生命周期状态机 E2E 20/20 PASS,下一步 Task 9 前端 mediasoup-client 接入)
|
||||
|
||||
---
|
||||
|
||||
@@ -351,24 +351,44 @@ flowchart LR
|
||||
- 单元测试(`httptest.NewServer` 模拟)本次未落地,用更真实的"Go + 真 Node + Playwright-style E2E"替代,证据力更强;Task 10(可观测性)补齐单测
|
||||
- **实际工作量**:**0.5 人日**(符合预估)
|
||||
|
||||
### Task 8:会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL)
|
||||
### Task 8:会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL)✅ 已完成
|
||||
|
||||
- **目标**:实现设计 §6.5 的状态机完整逻辑
|
||||
- **依赖**:T5
|
||||
- **主要产出**:
|
||||
- `app/meeting/service/meeting_service.go` 补充:
|
||||
- `OnHostDisconnect(ctx, roomCode)` → 写 `echo:meeting:host_grace:{code}` EX 120s
|
||||
- `OnHostReconnect(ctx, roomCode, userID)` → 清除宽限期键
|
||||
- `HandleHostGraceExpired(ctx, roomCode)` → 转让主持(挑最早加入者)或销毁会议
|
||||
- `OnAllMembersLeft(ctx, roomCode)` → 设置房间 Redis key TTL 300s
|
||||
- `OnRoomTTLExpired(ctx, roomCode)` → `status=2, ended_reason=empty_ttl`,清理 Node 资源
|
||||
- `app/meeting/task/meeting_cleanup_task.go`:每 30 秒扫描 Redis TTL + DB 状态,兜底清理
|
||||
- 事务包裹主持人转让(见设计 §11.3)
|
||||
- **检查点**:
|
||||
- 手测:host 关闭浏览器 → 2 分钟内重连,身份保留 → 超过 2 分钟自动转让给另一成员
|
||||
- 手测:全员退出 → 5 分钟后 DB `status=2`、Redis key 清空
|
||||
- 单元测试覆盖:转让事务回滚、并发转让竞态
|
||||
- **工作量**:**0.5 人日**
|
||||
- **目标**:实现设计 §6.5 的状态机完整逻辑;同步修复 Task 7 遗留的"CreateRoom/JoinRoom 各自调一次 `CreateRouter`"行为
|
||||
- **依赖**:T5 / T7
|
||||
- **实际产出**:
|
||||
- `backend/go-service/config/config.go` + `config.dev.yaml` / `config.docker.yaml`:新增 `MeetingConfig{HostGraceSeconds=120, EmptyRoomTTLSeconds=300, CleanupIntervalSeconds=30, StaleRoomHours=4}`
|
||||
- `backend/go-service/app/meeting/service/meeting_lifecycle_service.go`(新,470 行):
|
||||
- `OnHostDisconnect(ctx, roomCode, hostID)` → 写 `echo:meeting:host_grace:{code}` + 本地 `time.AfterFunc`,Redis TTL = 业务时长 + `max(cleanup*2, 30s)` buffer
|
||||
- `OnHostReconnect(ctx, roomCode, hostID)` → `DEL` key + 取消 timer
|
||||
- `HandleHostGraceExpired(ctx, roomCode)` → `DEL` key(返回 1 确认唯一处理权)→ `TransferHost` 事务(最早加入者)+ `UpdateHost` + 广播 `meeting.host.changed{auto_reason=host_grace_expired}`;无其他活跃成员则走 `OnAllMembersLeft`
|
||||
- `OnAllMembersLeft(ctx, roomCode)` → 写 `echo:meeting:empty_ttl:{code}` + 本地 timer
|
||||
- `CancelEmptyTTL(ctx, roomCode)` → `DEL` key + 取消 timer(新成员加入复活)
|
||||
- `HandleEmptyRoomExpired(ctx, roomCode)` → `DEL` key → `MarkEnded(reason=empty_ttl)` + `CloseRouter` 幂等 + 广播 `meeting.room.ended`
|
||||
- `RescheduleFromRedis` / `ScanExpired` / `HostGracePrefix` / `EmptyTTLPrefix` 辅助(供 cleanup task 兜底)
|
||||
- `backend/go-service/app/meeting/dao/meeting_room_dao.go`:新增 `ListStaleActive(hoursAgo, limit)` 扫陈旧活跃房间
|
||||
- `backend/go-service/app/meeting/service/interfaces.go`:`MediaOrchestrator` 新增 `ResolveRouterID(roomCode) (string, bool)`
|
||||
- `backend/go-service/app/meeting/service/http_media_orchestrator.go`:`CreateRouter` 入口查 `sync.Map` 命中直接返回(HTTP 层幂等防御);实现 `ResolveRouterID` 纯缓存读
|
||||
- `backend/go-service/app/meeting/service/meeting_service.go`:`JoinRoom` 移除 `CreateRouter`,改调 `lifecycleSvc.CancelEmptyTTL` + `ResolveRouterID`;`LeaveRoom` 空房分支改调 `lifecycleSvc.OnAllMembersLeft`
|
||||
- `backend/go-service/app/meeting/service/meeting_signal_service.go`:新增 `OnWSDisconnect(userID)` 实现 `ws.MeetingDisconnectHook`;`OnRoomJoin` 追加 host 重连钩子
|
||||
- `backend/go-service/app/ws/handler.go`:定义 `MeetingDisconnectHook` 接口 + `SetMeetingDisconnectHook`,`SetOnDisconnect` 末尾 invoke
|
||||
- `backend/go-service/app/meeting/task/meeting_cleanup_task.go`(新,220 行):启动时 `RescheduleFromRedis`;每 `CleanupIntervalSeconds` 秒扫 `host_grace:*` / `empty_ttl:*` 兜底 + 扫 stale active rooms → `MarkEnded(reason=system_error)`;每小时清理 Ended 会议的聊天记录
|
||||
- `backend/go-service/app/provider/{provider,wire,wire_gen}.go` + `cmd/server/main.go`:注入 `MeetingLifecycleService` / `MeetingCleanupTask` + `wire.Bind(new(ws.MeetingDisconnectHook), new(*service.MeetingSignalService))` + 启动/停止 cleanup task
|
||||
- `media-server/src/app.ts`:`/internal/info` 追加 `stats.routers` 字段供 E2E 断言 Router 幂等
|
||||
- `docs/verify/meeting_t8_verify.mjs`(新,320 行):5 场景 E2E 脚本
|
||||
- **实际检查点**(全部通过):
|
||||
- `go build ./...` / `go vet ./...` / `ReadLints` 全绿(保留 Task 7 遗留 pre-existing 警告)
|
||||
- E2E 脚本 `meeting_t8_verify.mjs` **20/20 PASS**(含 Router 幂等断言)
|
||||
- 场景覆盖:
|
||||
- S1:host 掉线 3s 后宽限期过期 → 自动转让给 B,`meeting.host.changed` 广播 `auto_reason=host_grace_expired`,DB `host_id` 更新
|
||||
- S2:host 掉线后 1s 内重连 → `host_grace` key DEL,身份保留
|
||||
- S3:全员 leave → `empty_ttl` 写入 → C 加入 → key DEL + 房间仍 Active
|
||||
- S4:全员 leave → 3s 后 `empty_ttl` 过期 → 房间 Ended → 再 join 被拒
|
||||
- S5:`CreateRoom` 使 `stats.routers` +1;`JoinRoom` 不再触发新 Router
|
||||
- **关键设计决策**:
|
||||
- **Redis TTL 加 buffer 避免 DEL 与自动过期并发误判**:E2E 调试时发现若 Redis key TTL 与本地 `time.AfterFunc` 时长相同,本地 timer 触发时 key 已被 Redis 自动删除,`DEL` 返回 0 被误判为"已被其他路径处理"而跳过业务逻辑;修复:Redis TTL = 业务时长 + `max(CleanupIntervalSeconds*2, 30s)`
|
||||
- **普通成员 WS 断线不动 DB**(决策 `q1_nonhost_disconnect=a1_keep_current`):仅清理 WS/media 资源,`meeting_participants` 记录保留;长期不活跃由 4 小时后台扫描兜底
|
||||
- **Router 幂等双层防御**(决策 `q2_router_dedup=a2_both`):业务层 `JoinRoom` 不调 `CreateRouter` + HTTP 层 `CreateRouter` 入口查 `sync.Map` 幂等
|
||||
- **实际工作量**:**0.5 人日**(符合预估)
|
||||
|
||||
### Task 9:前端 mediasoup-client 集成 + Pinia Store
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# EchoChat 项目开发进度
|
||||
|
||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 7 HTTPMediaOrchestrator 落地,Go↔Node media-server HTTP 链路真实打通,端到端 16/16 PASS)
|
||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-7 ✅ / Task 8-16 待执行)
|
||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 8 会议生命周期状态机落地,host 宽限期 + 自动转让 + 空房 TTL + Router 幂等,E2E 20/20 PASS)
|
||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-8 ✅ / Task 9-16 待执行)
|
||||
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
||||
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
|
||||
@@ -170,6 +170,87 @@
|
||||
|
||||
---
|
||||
|
||||
## 🎯 2026-04-21 Phase 2e-2 Task 8 会议生命周期状态机落地(host 宽限期 + 自动转让 + 空房 TTL + Router 幂等)
|
||||
|
||||
**交付**:新增 `MeetingLifecycleService` + `MeetingCleanupTask`,落地设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期、宽限期内重连、自动转让、空房 TTL、TTL 过期销毁);同步修复 Task 7 遗留的 "CreateRoom/JoinRoom 各自调一次 `CreateRouter`" 行为,业务层 `JoinRoom` 不再触发 `CreateRouter` + HTTP 层 `HTTPMediaOrchestrator.CreateRouter` 增加 `sync.Map` 幂等防御;端到端验证脚本 `docs/verify/meeting_t8_verify.mjs` **20/20 PASS**,覆盖 5 个核心场景(含 Router 数量断言)。
|
||||
|
||||
### 产出文件
|
||||
|
||||
| 文件 | 行数 | 作用 |
|
||||
|---|---|---|
|
||||
| `backend/go-service/config/config.go`(改) | +14 | 新增 `MeetingConfig{HostGraceSeconds, EmptyRoomTTLSeconds, CleanupIntervalSeconds, StaleRoomHours}` |
|
||||
| `backend/go-service/config/config.dev.yaml` / `config.docker.yaml`(改) | +7 | `meeting` 段默认值:`host_grace_seconds=120` / `empty_room_ttl_seconds=300` / `cleanup_interval_seconds=30` / `stale_room_hours=4` |
|
||||
| `backend/go-service/app/constants/meeting.go`(改) | +10 | `MeetingHostGraceSeconds=120` / `MeetingEmptyRoomTTLSeconds=300` / `MeetingEndedReasonEmptyTTL="empty_ttl"` / `MeetingLeftReasonDisconnect="disconnect"` 等兜底常量 |
|
||||
| `backend/go-service/app/meeting/service/meeting_lifecycle_service.go`(新) | 470 | `MeetingLifecycleService`:5 钩子(`OnHostDisconnect` / `OnHostReconnect` / `HandleHostGraceExpired` / `OnAllMembersLeft` / `CancelEmptyTTL` / `HandleEmptyRoomExpired`)+ `sync.Map` 本地 timer + `RescheduleFromRedis` 重启恢复 + `ScanExpired` 兜底扫描;Redis key TTL = 业务时长 + buffer(`max(cleanup*2, 30s)`)避免与本地 timer 同时到期导致 DEL 误判 |
|
||||
| `backend/go-service/app/meeting/dao/meeting_room_dao.go`(改) | +20 | 新增 `ListStaleActive(hoursAgo, limit)`:扫描 `status != Ended && COALESCE(started_at, created_at) < NOW() - N 小时` 的房间,供 cleanup task 兜底回收 |
|
||||
| `backend/go-service/app/meeting/service/interfaces.go`(改) | +8 | `MediaOrchestrator` 新增 `ResolveRouterID(roomCode) (string, bool)`,让业务层直接查缓存而非重复创建;`NoopMediaOrchestrator` 同步实现 |
|
||||
| `backend/go-service/app/meeting/service/http_media_orchestrator.go`(改) | +12 | `CreateRouter` 入口先查 `sync.Map` 命中则直接返回(HTTP 层幂等防御);新增 `ResolveRouterID` 纯缓存读 |
|
||||
| `backend/go-service/app/meeting/service/meeting_service.go`(改) | ±25 | `JoinRoom` 移除 `CreateRouter` 调用,改为 `lifecycleSvc.CancelEmptyTTL` 复活空房 + `mediaOrchestrator.ResolveRouterID` 返回已缓存的 router_id;`LeaveRoom` 空房分支改调 `lifecycleSvc.OnAllMembersLeft`(启动 empty_ttl),不再立即 `MarkEnded` |
|
||||
| `backend/go-service/app/meeting/service/meeting_signal_service.go`(改) | +60 | 新增 `OnWSDisconnect(userID)` 实现 `ws.MeetingDisconnectHook`:清理该用户所有 media 资源;若为 host 调 `lifecycleSvc.OnHostDisconnect` 启动宽限期。`OnRoomJoin` 新增"若为 host 调 `lifecycleSvc.OnHostReconnect`"逻辑 |
|
||||
| `backend/go-service/app/ws/handler.go`(改) | +30 | 新增 `MeetingDisconnectHook` 接口 + `SetMeetingDisconnectHook`;`SetOnDisconnect` 回调末尾 invoke `OnWSDisconnect`,解除 ws/meeting 循环依赖 |
|
||||
| `backend/go-service/app/meeting/task/meeting_cleanup_task.go`(新) | 220 | `MeetingCleanupTask`:启动时 `RescheduleFromRedis` 重建本地 timer;周期性 `ScanExpired` 扫 `host_grace:*` / `empty_ttl:*` 兜底触发;周期性扫 stale active rooms → `MarkEnded(reason=system_error)`;每小时清理已结束会议的旧聊天(保留 48h) |
|
||||
| `backend/go-service/app/meeting/provider.go`(改) | +4 | `MeetingSet` 追加 `NewMeetingLifecycleService` / `NewMeetingCleanupTask` |
|
||||
| `backend/go-service/app/provider/{provider,wire,wire_gen}.go`(改) | +25 | `App` 结构体新增 `MeetingLifecycleSvc` / `MeetingCleanupTask` 字段;`wire.Bind(new(ws.MeetingDisconnectHook), new(*service.MeetingSignalService))` 完成钩子注入 |
|
||||
| `backend/go-service/cmd/server/main.go`(改) | +5 | 服务启动 `app.MeetingCleanupTask.Start()`;优雅退出 `defer app.MeetingCleanupTask.Stop()` |
|
||||
| `media-server/src/app.ts`(改) | +8 | `/internal/info` 响应追加 `stats.routers`(总数)+ `routers[]`(roomCode/routerId/ageMs)供 E2E 断言 Router 幂等 |
|
||||
| `docs/verify/meeting_t8_verify.mjs`(新) | 320 | E2E 验证脚本:5 个场景覆盖 host 宽限期过期/重连保留、空房 TTL 复活/过期销毁、JoinRoom 不重复调 `CreateRouter`;通过 `ECHOCHAT_MEETING_HOST_GRACE_SECONDS` 等 env 加速;直接 Redis 断言 key 状态,不依赖日志 |
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
1. **本地 timer + Redis key 双保险**:低延迟由 `time.AfterFunc` 触发;单实例进程重启由 `RescheduleFromRedis` 按 Redis `PTTL` 重建;多实例 / timer 丢失场景由后台 `MeetingCleanupTask` 每 `CleanupIntervalSeconds` 秒兜底扫描。
|
||||
2. **Redis key TTL 加 buffer**:Redis key TTL = 业务时长 + `max(CleanupIntervalSeconds*2, 30s)`,保证本地 timer 先触发,`DEL` 能命中;否则若两者同时到期,Redis 自动过期 key 会导致 `DEL` 返回 0 被误判为 "已被其他节点处理" 而跳过业务逻辑(E2E 调试过程实测到此 bug 并修复)。
|
||||
3. **并发去重**:`HandleHostGraceExpired` / `HandleEmptyRoomExpired` 入口一律先 `DEL` Redis key,返回 `1` 才继续执行业务逻辑(转让 / 销毁);返回 `0` 直接返回,避免多实例 / 多触发路径重复操作。
|
||||
4. **Router 幂等双层防御**:
|
||||
- **业务层**:`JoinRoom` 不再调 `CreateRouter`,改为 `ResolveRouterID` 查缓存;Router 创建只在 `CreateRoom` 发生一次。
|
||||
- **HTTP 层**:`HTTPMediaOrchestrator.CreateRouter` 入口查 `sync.Map(roomCode -> routerID)`,命中直接返回缓存值,不发 HTTP。
|
||||
- E2E 场景 5 断言:`CreateRoom` 后 `stats.routers` +1,`JoinRoom` 后 `stats.routers` 不变。
|
||||
5. **WS 断开钩子通过接口解耦**:`ws` 包定义 `MeetingDisconnectHook` 接口并通过 `SetMeetingDisconnectHook` 注入,避免 `ws → meeting` 包反向依赖;`MeetingSignalService` 实现该接口。普通成员 WS 断开仅清理 media 资源(设计决策 `q1_nonhost_disconnect=a1_keep_current`:不改 `meeting_participants` 记录,长期不活跃由 4 小时后台清理兜底)。
|
||||
6. **全员 leave 不再立即销毁房间**:`LeaveRoom` 检测到活跃成员归零时调 `OnAllMembersLeft` 启动 `empty_ttl`,TTL 内有新成员加入(`JoinRoom` 调 `CancelEmptyTTL`)则房间复活;TTL 过期才 `MarkEnded(reason=empty_ttl) + CloseRouter`。
|
||||
|
||||
### E2E 验证(20/20 PASS)
|
||||
|
||||
```text
|
||||
Config: HOST_GRACE=3s EMPTY_TTL=3s CLEANUP=1s
|
||||
PASS: media-server healthz
|
||||
-- Scenario 1: host grace expired → auto transfer --
|
||||
PASS: S1: create room ok
|
||||
PASS: S1: host_grace key written after host disconnect
|
||||
PASS: S1: host_grace key cleared after expiry
|
||||
PASS: S1: meeting.host.changed broadcasted
|
||||
PASS: S1: new_host_id = B
|
||||
PASS: S1: auto_reason = host_grace_expired
|
||||
PASS: S1: DB host_id updated to B
|
||||
-- Scenario 2: host reconnect within grace period --
|
||||
PASS: S2: host_grace key written
|
||||
PASS: S2: host_grace key DEL on reconnect
|
||||
PASS: S2: host identity preserved
|
||||
-- Scenario 3: empty_ttl revival on new join --
|
||||
PASS: S3: empty_ttl key written on empty room
|
||||
PASS: S3: new user join succeeds within TTL
|
||||
PASS: S3: empty_ttl key DEL on join
|
||||
PASS: S3: room remains Active after revival
|
||||
-- Scenario 4: empty_ttl expiry → room Ended --
|
||||
PASS: S4: empty_ttl set
|
||||
PASS: S4: empty_ttl cleared after expiry
|
||||
PASS: S4: join Ended room is rejected
|
||||
-- Scenario 5: JoinRoom should NOT create duplicate Router --
|
||||
PASS: S5: Router +1 after CreateRoom
|
||||
PASS: S5: Router count unchanged after JoinRoom (no duplicate)
|
||||
PASS=20 FAIL=0
|
||||
```
|
||||
|
||||
### 已知待改进项(留给后续 Task)
|
||||
|
||||
1. **`rescheduleByPattern` 重建 timer 使用完整 Redis PTTL**:当前服务重启后,本地 timer 会按 "业务时长 + buffer" 的完整 PTTL 重建,导致 worst-case 触发延迟最多晚 buffer 秒(默认 30s)。后续可在 `host_grace` 的 payload 中引入 `grace_until` 字段 + `empty_ttl` 改 JSON payload 存 `until`,按业务到期时间精确恢复。
|
||||
2. **cleanup task 判定基于 Redis TTL**:`ScanExpired` 当前按 `PTTL <= 0` 判定过期,这意味着兜底触发时间晚于"业务到期时间" buffer 秒。正常情况下本地 timer 先触发,兜底仅处理 timer 丢失 / 多实例场景,延迟可接受;若需要精准兜底可按 payload.until 对比 `time.Now()`。
|
||||
3. **多实例 grace_lock**:设计 §5.4 预留 `echo:meeting:grace_lock:{code}` 用于多实例并发保护,当前单实例部署未实装,Phase 2e-3 多实例化时补齐。
|
||||
|
||||
### 下一步
|
||||
|
||||
进入 **Task 9:Vue 前端 mediasoup-client 接入 + 会议室页面骨架**,依赖 Task 6 的 WS 信令契约与 Task 7 的真实 media 链路;预估 2 人日。
|
||||
|
||||
---
|
||||
|
||||
## 🎯 2026-04-21 Phase 2e-2 Task 7 HTTPMediaOrchestrator 落地(Go↔Node 真实媒体链路打通)
|
||||
|
||||
**交付**:将 `NoopMediaOrchestrator` 替换为真实的 `HTTPMediaOrchestrator`,`MeetingService` / `MeetingSignalService` 的 9 个 media 调用全部走 HTTP 到 Node `media-server` 的 `/internal/v1/*` API;端到端验证脚本 `docs/verify/meeting_t7_verify.mjs` **16/16 PASS**,日志确认 Go 真正触发 mediasoup 真实 `router created` / `webrtc transport created` / `router closed explicitly` 事件。
|
||||
|
||||
318
docs/verify/meeting_t8_verify.mjs
Normal file
318
docs/verify/meeting_t8_verify.mjs
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
// Task 8 端到端验证脚本:会议生命周期状态机
|
||||
// 覆盖:
|
||||
// 1. host 掉线 → 宽限期过期自动转让(其他成员收 host.changed,DB host_id 更新)
|
||||
// 2. host 掉线 → 宽限期内重连 → host 身份保留
|
||||
// 3. 全员 leave → empty_ttl 存在 → 新用户 join 复活(empty_ttl key 被 DEL)
|
||||
// 4. 全员 leave → 等待 TTL 过期 → Node Router 被关闭(CloseRouter 日志佐证)+ DB 状态 Ended
|
||||
// 5. JoinRoom 不再重复调 CreateRouter(Node 侧仅 1 次 router created 日志)
|
||||
//
|
||||
// 运行前置:
|
||||
// - 必须设置短时长环境变量加速触发,推荐:
|
||||
// ECHOCHAT_MEETING_HOST_GRACE_SECONDS=3
|
||||
// ECHOCHAT_MEETING_EMPTY_ROOM_TTL_SECONDS=3
|
||||
// ECHOCHAT_MEETING_CLEANUP_INTERVAL_SECONDS=1
|
||||
// 然后启动 go-service
|
||||
// - media-server 必须已启动(端口 3300)
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
const GO = 'http://localhost:8085/api/v1';
|
||||
const NODE = 'http://localhost:3300';
|
||||
const NODE_TOKEN = 'dev-token-abcdef1234567890';
|
||||
|
||||
// 兼容上游通过环境变量传入缩短后的时长(默认与 E2E 推荐一致)
|
||||
const HOST_GRACE_SECONDS = Number(process.env.ECHOCHAT_MEETING_HOST_GRACE_SECONDS || 3);
|
||||
const EMPTY_TTL_SECONDS = Number(process.env.ECHOCHAT_MEETING_EMPTY_ROOM_TTL_SECONDS || 3);
|
||||
const CLEANUP_SECONDS = Number(process.env.ECHOCHAT_MEETING_CLEANUP_INTERVAL_SECONDS || 1);
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const assert = (cond, name, detail) => {
|
||||
if (cond) {
|
||||
pass++;
|
||||
console.log(`PASS: ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(`FAIL: ${name}`, detail ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
// ============ HTTP utils ============
|
||||
|
||||
async function httpJSON(method, url, body, headers = {}) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json', ...headers } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (_) { /* non-json */ }
|
||||
return { status: r.status, data, text };
|
||||
}
|
||||
|
||||
async function register(suffix) {
|
||||
const email = `t8user${suffix}@test.local`;
|
||||
const password = 'TestPassword123!';
|
||||
const username = `t8user${suffix}`;
|
||||
await httpJSON('POST', `${GO}/auth/register`, { email, username, password });
|
||||
const login = await httpJSON('POST', `${GO}/auth/login`, { account: email, password });
|
||||
if (!login.data?.data?.token) throw new Error(`login failed ${JSON.stringify(login)}`);
|
||||
return { token: login.data.data.token, userID: login.data.data.user?.id };
|
||||
}
|
||||
|
||||
// ============ WS utils ============
|
||||
|
||||
function connectWS(token) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://localhost:8085/ws?token=${token}`);
|
||||
ws.on('open', () => resolve(ws));
|
||||
ws.on('error', reject);
|
||||
setTimeout(() => reject(new Error('ws open timeout')), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
let _seqCounter = 1;
|
||||
function wsSend(ws, event, data) {
|
||||
return new Promise((resolve) => {
|
||||
const seq = _seqCounter++;
|
||||
const ackEvent = `${event}.ack`;
|
||||
const onMsg = (raw) => {
|
||||
try {
|
||||
const m = JSON.parse(raw.toString());
|
||||
if (m.event === ackEvent && m.seq === seq) {
|
||||
ws.off('message', onMsg);
|
||||
resolve(m);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.on('message', onMsg);
|
||||
ws.send(JSON.stringify({ event, seq, data, time: new Date().toISOString() }));
|
||||
setTimeout(() => {
|
||||
ws.off('message', onMsg);
|
||||
resolve(null);
|
||||
}, 6000);
|
||||
});
|
||||
}
|
||||
|
||||
// 收集指定 event 的推送消息到内存 buffer(用于非 ACK 广播事件断言)
|
||||
function collectPush(ws, eventName, buffer) {
|
||||
const listener = (raw) => {
|
||||
try {
|
||||
const m = JSON.parse(raw.toString());
|
||||
if (m.event === eventName) buffer.push(m);
|
||||
} catch {}
|
||||
};
|
||||
ws.on('message', listener);
|
||||
return () => ws.off('message', listener);
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// ============ Redis utils(直接断言 key 状态,避免依赖服务日志)============
|
||||
|
||||
async function connectRedis() {
|
||||
const c = createClient({ url: 'redis://localhost:6379/0' });
|
||||
await c.connect();
|
||||
return c;
|
||||
}
|
||||
|
||||
// ============ Main ============
|
||||
|
||||
(async () => {
|
||||
console.log('==== Task 8 Meeting Lifecycle E2E Verify ====');
|
||||
console.log(`Config: HOST_GRACE=${HOST_GRACE_SECONDS}s EMPTY_TTL=${EMPTY_TTL_SECONDS}s CLEANUP=${CLEANUP_SECONDS}s`);
|
||||
|
||||
const redis = await connectRedis();
|
||||
|
||||
// 预检:media-server 活着
|
||||
const mHealth = await httpJSON('GET', `${NODE}/healthz`);
|
||||
assert(mHealth.status === 200 && mHealth.data?.ok === true, 'media-server healthz');
|
||||
|
||||
const ts = Date.now().toString().slice(-6);
|
||||
|
||||
// ===== 场景 1:host 宽限期过期自动转让 =====
|
||||
console.log('\n-- Scenario 1: host grace expired → auto transfer --');
|
||||
{
|
||||
const a = await register(`a1${ts}`);
|
||||
const b = await register(`b1${ts}`);
|
||||
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
|
||||
title: 'T8 Scene1', type: 1,
|
||||
}, { Authorization: `Bearer ${a.token}` });
|
||||
assert(createRes.status === 201, 'S1: create room ok');
|
||||
const code = createRes.data?.data?.room?.room_code;
|
||||
const roomID = createRes.data?.data?.room?.id;
|
||||
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
|
||||
|
||||
const wsA = await connectWS(a.token);
|
||||
const wsB = await connectWS(b.token);
|
||||
await wsSend(wsA, 'meeting.room.join', { room_code: code });
|
||||
await wsSend(wsB, 'meeting.room.join', { room_code: code });
|
||||
|
||||
// 监听 B 上的 host.changed 广播
|
||||
const hostChangedBuf = [];
|
||||
const stopCollect = collectPush(wsB, 'meeting.host.changed', hostChangedBuf);
|
||||
|
||||
// A(host)断开 WS → 写入 host_grace key
|
||||
wsA.close();
|
||||
await sleep(500);
|
||||
const graceExists = await redis.exists(`echo:meeting:host_grace:${code}`);
|
||||
assert(graceExists === 1, 'S1: host_grace key written after host disconnect');
|
||||
|
||||
// 等宽限期 + cleanup 一轮
|
||||
await sleep((HOST_GRACE_SECONDS + CLEANUP_SECONDS + 1) * 1000);
|
||||
|
||||
const graceGone = await redis.exists(`echo:meeting:host_grace:${code}`);
|
||||
assert(graceGone === 0, 'S1: host_grace key cleared after expiry');
|
||||
assert(hostChangedBuf.length >= 1, 'S1: meeting.host.changed broadcasted', hostChangedBuf);
|
||||
if (hostChangedBuf.length > 0) {
|
||||
const payload = hostChangedBuf[0]?.data;
|
||||
assert(payload?.new_host_id === b.userID, 'S1: new_host_id = B', payload);
|
||||
assert(payload?.auto_reason === 'host_grace_expired', 'S1: auto_reason = host_grace_expired', payload);
|
||||
}
|
||||
|
||||
// DB 状态:通过 REST /rooms/:code 查看 host_id(B 的视角)
|
||||
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${b.token}` });
|
||||
assert(roomInfo.data?.data?.room?.host_id === b.userID, 'S1: DB host_id updated to B', roomInfo.data);
|
||||
|
||||
stopCollect();
|
||||
wsB.close();
|
||||
// 清理会议(通过新 host B)
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${b.token}` });
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ===== 场景 2:host 宽限期内重连保留身份 =====
|
||||
console.log('\n-- Scenario 2: host reconnect within grace period --');
|
||||
{
|
||||
const a = await register(`a2${ts}`);
|
||||
const b = await register(`b2${ts}`);
|
||||
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
|
||||
title: 'T8 Scene2', type: 1,
|
||||
}, { Authorization: `Bearer ${a.token}` });
|
||||
const code = createRes.data?.data?.room?.room_code;
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
|
||||
|
||||
let wsA = await connectWS(a.token);
|
||||
const wsB = await connectWS(b.token);
|
||||
await wsSend(wsA, 'meeting.room.join', { room_code: code });
|
||||
await wsSend(wsB, 'meeting.room.join', { room_code: code });
|
||||
|
||||
// A 掉线 → 立刻重连(远小于 HOST_GRACE_SECONDS)
|
||||
wsA.close();
|
||||
await sleep(500);
|
||||
const graceExists = await redis.exists(`echo:meeting:host_grace:${code}`);
|
||||
assert(graceExists === 1, 'S2: host_grace key written');
|
||||
|
||||
// 重连 + room.join 触发 OnHostReconnect
|
||||
wsA = await connectWS(a.token);
|
||||
await wsSend(wsA, 'meeting.room.join', { room_code: code });
|
||||
await sleep(500);
|
||||
|
||||
const graceGone = await redis.exists(`echo:meeting:host_grace:${code}`);
|
||||
assert(graceGone === 0, 'S2: host_grace key DEL on reconnect');
|
||||
|
||||
// 继续等过完原本的宽限期 + cleanup,无 host 变更即通过
|
||||
await sleep((HOST_GRACE_SECONDS + CLEANUP_SECONDS + 1) * 1000);
|
||||
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${a.token}` });
|
||||
assert(roomInfo.data?.data?.room?.host_id === a.userID, 'S2: host identity preserved', roomInfo.data);
|
||||
|
||||
wsA.close();
|
||||
wsB.close();
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${a.token}` });
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
// ===== 场景 3:空房 TTL 复活 =====
|
||||
console.log('\n-- Scenario 3: empty_ttl revival on new join --');
|
||||
{
|
||||
const a = await register(`a3${ts}`);
|
||||
const c = await register(`c3${ts}`);
|
||||
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
|
||||
title: 'T8 Scene3', type: 1,
|
||||
}, { Authorization: `Bearer ${a.token}` });
|
||||
const code = createRes.data?.data?.room?.room_code;
|
||||
|
||||
// host 自己 leave → 空房 TTL 启动
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${a.token}` });
|
||||
await sleep(300);
|
||||
const ttlExists = await redis.exists(`echo:meeting:empty_ttl:${code}`);
|
||||
assert(ttlExists === 1, 'S3: empty_ttl key written on empty room');
|
||||
|
||||
// TTL 内 C 新加入 → 撤销 TTL,房间保持 Active
|
||||
const joinRes = await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${c.token}` });
|
||||
assert(joinRes.status === 200, 'S3: new user join succeeds within TTL', joinRes.data);
|
||||
await sleep(300);
|
||||
const ttlGone = await redis.exists(`echo:meeting:empty_ttl:${code}`);
|
||||
assert(ttlGone === 0, 'S3: empty_ttl key DEL on join');
|
||||
|
||||
const roomInfo = await httpJSON('GET', `${GO}/meeting/rooms/${code}`, undefined, { Authorization: `Bearer ${c.token}` });
|
||||
assert(roomInfo.data?.data?.room?.status === 1, 'S3: room remains Active after revival', roomInfo.data);
|
||||
|
||||
// 清理:C 结束或 leave(C 非 host,走 leave→空房→TTL;等其过期兜底)
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${c.token}` });
|
||||
await sleep((EMPTY_TTL_SECONDS + CLEANUP_SECONDS + 1) * 1000);
|
||||
}
|
||||
|
||||
// ===== 场景 4:空房 TTL 过期 → 自动销毁 =====
|
||||
console.log('\n-- Scenario 4: empty_ttl expiry → room Ended --');
|
||||
{
|
||||
const a = await register(`a4${ts}`);
|
||||
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
|
||||
title: 'T8 Scene4', type: 1,
|
||||
}, { Authorization: `Bearer ${a.token}` });
|
||||
const code = createRes.data?.data?.room?.room_code;
|
||||
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/leave`, {}, { Authorization: `Bearer ${a.token}` });
|
||||
await sleep(300);
|
||||
assert((await redis.exists(`echo:meeting:empty_ttl:${code}`)) === 1, 'S4: empty_ttl set');
|
||||
|
||||
await sleep((EMPTY_TTL_SECONDS + CLEANUP_SECONDS + 1) * 1000);
|
||||
assert((await redis.exists(`echo:meeting:empty_ttl:${code}`)) === 0, 'S4: empty_ttl cleared after expiry');
|
||||
|
||||
// 会议已结束 → 再 join 报已结束
|
||||
const b = await register(`b4${ts}`);
|
||||
const joinAgain = await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
|
||||
assert(joinAgain.status === 400 || joinAgain.status === 404 || joinAgain.status === 410,
|
||||
'S4: join Ended room is rejected', joinAgain.data);
|
||||
}
|
||||
|
||||
// ===== 场景 5:JoinRoom 不再重复调 CreateRouter =====
|
||||
console.log('\n-- Scenario 5: JoinRoom should NOT create duplicate Router --');
|
||||
{
|
||||
// 记录 Node 当前 Router 数(通过 /internal/info)
|
||||
const info0 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
|
||||
const routers0 = info0.data?.stats?.routers ?? info0.data?.routers ?? 0;
|
||||
|
||||
const a = await register(`a5${ts}`);
|
||||
const b = await register(`b5${ts}`);
|
||||
const createRes = await httpJSON('POST', `${GO}/meeting/rooms`, {
|
||||
title: 'T8 Scene5', type: 1,
|
||||
}, { Authorization: `Bearer ${a.token}` });
|
||||
const code = createRes.data?.data?.room?.room_code;
|
||||
|
||||
// 创建后应 +1 个 Router
|
||||
const info1 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
|
||||
const routers1 = info1.data?.stats?.routers ?? info1.data?.routers ?? 0;
|
||||
assert(routers1 - routers0 === 1, 'S5: Router +1 after CreateRoom', { routers0, routers1 });
|
||||
|
||||
// B 加入不应再增加 Router
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/join`, {}, { Authorization: `Bearer ${b.token}` });
|
||||
const info2 = await httpJSON('GET', `${NODE}/internal/info`, undefined, { 'X-Internal-Token': NODE_TOKEN });
|
||||
const routers2 = info2.data?.stats?.routers ?? info2.data?.routers ?? 0;
|
||||
assert(routers2 === routers1, 'S5: Router count unchanged after JoinRoom (no duplicate)', { routers1, routers2 });
|
||||
|
||||
// 清理
|
||||
await httpJSON('POST', `${GO}/meeting/rooms/${code}/end`, {}, { Authorization: `Bearer ${a.token}` });
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
await redis.quit();
|
||||
console.log('-----');
|
||||
console.log(`PASS=${pass} FAIL=${fail}`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
})().catch((err) => {
|
||||
console.error('script error:', err);
|
||||
process.exit(2);
|
||||
});
|
||||
Reference in New Issue
Block a user