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:
bujinyuan
2026-04-21 18:21:04 +08:00
parent 55d6352c6a
commit 3b83c79036
23 changed files with 1344 additions and 48 deletions

View File

@@ -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` 内联按钮

View File

@@ -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-712 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-812 REST + 13 WS 事件 + HTTPMediaOrchestrator 真实媒体链路 + MeetingLifecycleService + MeetingCleanupTask + Router 幂等双层防御Task 9-16 待执行);专用设计:`docs/plans/2026-04-21-phase2e-2-design.md` |
| notify | 统一通知中心、好友/群聊事件 + 系统广播推送、11 种类型枚举、跨模块 Pusher 接口注入 | ✅ Phase 2e-110 种落地 + 2 种预留 `meeting_invite` / `meeting_reminder` |
| admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c |

View File

@@ -5,7 +5,7 @@
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-21Task 0-7 ✅ 已落地,Go↔Node HTTP 媒体链路打通,下一步 Task 8 生命周期状态机
> **最后更新:** 2026-04-21Task 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 幂等断言
- 场景覆盖
- S1host 掉线 3s 后宽限期过期 自动转让给 B`meeting.host.changed` 广播 `auto_reason=host_grace_expired`DB `host_id` 更新
- S2host 掉线后 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

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-04-21Phase 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-21Phase 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 9Vue 前端 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` 事件。

View File

@@ -0,0 +1,318 @@
#!/usr/bin/env node
// Task 8 端到端验证脚本:会议生命周期状态机
// 覆盖:
// 1. host 掉线 → 宽限期过期自动转让(其他成员收 host.changedDB host_id 更新)
// 2. host 掉线 → 宽限期内重连 → host 身份保留
// 3. 全员 leave → empty_ttl 存在 → 新用户 join 复活empty_ttl key 被 DEL
// 4. 全员 leave → 等待 TTL 过期 → Node Router 被关闭CloseRouter 日志佐证)+ DB 状态 Ended
// 5. JoinRoom 不再重复调 CreateRouterNode 侧仅 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);
// ===== 场景 1host 宽限期过期自动转让 =====
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);
// Ahost断开 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_idB 的视角)
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);
}
// ===== 场景 2host 宽限期内重连保留身份 =====
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 结束或 leaveC 非 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);
}
// ===== 场景 5JoinRoom 不再重复调 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);
});