--- name: Task 8 会议生命周期状态机 overview: 落地 Phase 2e-2 Task 8:host 掉线 2 分钟宽限期 + 自动转让、空房 5 分钟 TTL、后台定时清理兜底任务,同步修复 Task 7 遗留的 CreateRouter 重复调用。整体 0.5 人日。 archived_at: 2026-04-21 archived_note: | 本文件原为 Cursor Plan 模式临时产物(.cursor/plans/task_8_*.plan.md), Task 8 实施完成后归档至 docs/plans/ 供后续审计追溯。 实施结果与实际偏差见 docs/plans/2026-04-21-phase2e-2-implementation.plan.md §Task 8 与 docs/progress/CURRENT_STATUS.md 的 Task 8 交付条目;E2E 验证 20/20 PASS (docs/verify/meeting_t8_verify.mjs);最终 commit: 3b83c79。 todos: - id: t8-config content: config.go + yaml 新增 MeetingConfig(HostGrace/EmptyTTL/CleanupInterval/StaleRoomHours) status: completed - id: t8-lifecycle content: 新建 meeting_lifecycle_service.go(5 个钩子 + sync.Map timer + RescheduleFromRedis) status: completed - id: t8-dao content: MeetingRoomDAO 新增 ListStaleActive(2 行 SQL) status: completed - id: t8-service-fix content: meeting_service.go:JoinRoom 移除 CreateRouter + LeaveRoom 空房改调 OnAllMembersLeft + JoinRoom 复活 empty_ttl status: completed - id: t8-signal-fix content: meeting_signal_service.go:新增 OnWSDisconnect + OnRoomJoin 钩 host 重连 status: completed - id: t8-http-idempotent content: HTTPMediaOrchestrator.CreateRouter 加 sync.Map 幂等防御 status: completed - id: t8-ws-hook content: ws.handler 新增 MeetingDisconnectHook 接口注入 + wire.Bind status: completed - id: t8-task content: 新建 meeting_cleanup_task.go(30s 扰动过期 + 10min stale + 24h chat 清理) status: completed - id: t8-wire content: provider.go + wire.go + wire_gen.go 注入新加 5 个类型 + main.go 启动 CleanupTask status: completed - id: t8-build content: go build ./... + go vet + ReadLints 全绿 status: completed - id: t8-verify content: E2E 脚本 meeting_t8_verify.mjs(host 宽限期转让 / 重连 / 空房 TTL 复活 / 过期 Ended / Router 不再重复建) status: completed - id: t8-doc content: 更新 5 份文档:CURRENT_STATUS / implementation.plan / design / api/frontend/meeting / project-context status: completed - id: t8-commit content: "git commit feat(phase2e-2): Task 8 会议生命周期状态机" status: completed isProject: false --- ## 一、核心决策(回答已确认) - **普通成员 WS 断线(Q1=A)**:维持现状,仅清 WS 媒体资源,不动 `meeting_participants`;长期不活跃依赖 4h 兜底清理(Task 8 内实现) - **Router 去重(Q2=C)**:业务层 `JoinRoom` 不再调 `CreateRouter` + `HTTPMediaOrchestrator.CreateRouter` 加 sync.Map 幂等防御 - **过期检测**:`time.AfterFunc` 为主(低延迟)+ 后台 `meeting_cleanup_task` 每 30s 扫描兜底(服务重启/机器迁移场景) - **并发保护**:`HandleHostGraceExpired` 入口先 `DEL` host_grace key,DEL 返回 0 视为已被重连清走,直接 return(天然互斥) - **可测性**:`config.MeetingConfig{HostGraceSeconds, EmptyRoomTTLSeconds, CleanupIntervalSeconds}`,默认 120/300/30,E2E 测试可覆盖为 2/3/1 ## 二、状态机流转 ```mermaid stateDiagram-v2 [*] --> Active: CreateRoom Active --> HostGrace: OnHostDisconnect (host WS 掉线) HostGrace --> Active: OnHostReconnect (120s 内重连,DEL grace key) HostGrace --> Transferring: host_grace 过期 AfterFunc 触发 Transferring --> Active: 事务转让最早加入者 + 广播 host.changed Transferring --> EmptyTTL: 无其他活跃成员 Active --> EmptyTTL: OnAllMembersLeft (全员 Leave) EmptyTTL --> Active: JoinRoom 命中 empty_ttl key 复活 EmptyTTL --> Ended: 300s 过期 + CloseRouter + status=2 reason=empty_ttl Active --> Ended: host EndRoom (status=2 reason=host_ended) Ended --> [*] ``` ## 三、文件变更清单 ### 3.1 新建 - [`backend/go-service/app/meeting/service/meeting_lifecycle_service.go`](backend/go-service/app/meeting/service/meeting_lifecycle_service.go)(约 350 行) - 统一封装 5 个钩子:`OnHostDisconnect / OnHostReconnect / HandleHostGraceExpired / OnAllMembersLeft / HandleEmptyRoomExpired` - 内部 `sync.Map[roomCode]*time.Timer` 管理本地 AfterFunc - 暴露 `RescheduleFromRedis(ctx)`(服务启动时扫描已存在的 grace/empty_ttl key 补装 timer) - [`backend/go-service/app/meeting/task/meeting_cleanup_task.go`](backend/go-service/app/meeting/task/meeting_cleanup_task.go)(约 180 行) - 启动时先 `lifecycleSvc.RescheduleFromRedis` 一次 - 每 30s:扫 `echo:meeting:host_grace:*` 找出 `TTL ≤ 0` 或 timer 已丢失的 key → 再次触发 `HandleHostGraceExpired`;同理处理 `empty_ttl` key - 每 10 分钟:`roomDAO.ListStaleActive(hoursNoActivity=4)` → `EndRoom(..., reason=system_error)` 兜底 - 每 24 小时:`chatDAO.DeleteByRoomIDs(olderThan=24h)` 聊天清理 - [`docs/verify/meeting_t8_verify.mjs`](docs/verify/meeting_t8_verify.mjs)(约 250 行)E2E 脚本 ### 3.2 修改(业务逻辑) - [`backend/go-service/app/meeting/service/meeting_service.go`](backend/go-service/app/meeting/service/meeting_service.go) - `JoinRoom` L307-316:**移除** `mediaOrchestrator.CreateRouter` 调用,改为复用已存在 Router(查 Redis `echo:meeting:room:{code}` router_id 字段或直接从 HTTPMediaOrchestrator sync.Map 读) - `LeaveRoom` L381-384:全员退出时从"立即 MarkEnded+CloseRouter"改为**调 `lifecycleSvc.OnAllMembersLeft(ctx, code)`** - `JoinRoom` 新增 L260 后:检查 `echo:meeting:empty_ttl:{code}` 存在即 `lifecycleSvc.CancelEmptyTTL(ctx, code)` 恢复房间 - `CreateRoom` L213 保留(房间首创 Router) - [`backend/go-service/app/meeting/service/http_media_orchestrator.go`](backend/go-service/app/meeting/service/http_media_orchestrator.go) - `CreateRouter` 前置查 `roomRouterCache` sync.Map,命中即直接 return(底层幂等防御,对业务层误调零影响) - [`backend/go-service/app/meeting/service/meeting_signal_service.go`](backend/go-service/app/meeting/service/meeting_signal_service.go) - 新增公开方法 `OnWSDisconnect(ctx context.Context, userID int64)`:`participantDAO.FindActiveByUser` → 存在则调 `cleanupUserResources`;若 `room.HostID == userID` 则额外 `lifecycleSvc.OnHostDisconnect` - `OnRoomJoin` 新增 L112 前:若该用户是 room.HostID 则调 `lifecycleSvc.OnHostReconnect`(支持主持人重连恢复) ### 3.3 WS 断线钩子集成 - [`backend/go-service/app/ws/handler.go`](backend/go-service/app/ws/handler.go) - 新增字段 `meetingDisconnectHook MeetingDisconnectHook`(接口,可选,避免循环依赖) - `SetOnDisconnect` 回调 L115-123:在 `UserOffline` 之后追加 `hook.OnWSDisconnect(ctx, userID)` - `app/ws/interfaces.go`(新增 10 行)定义接口: ```go type MeetingDisconnectHook interface { OnWSDisconnect(ctx context.Context, userID int64) } ``` - `app/provider/wire.go`:`wire.Bind(new(ws.MeetingDisconnectHook), new(*service.MeetingSignalService))` ### 3.4 配置 + 启动 - [`backend/go-service/config/config.go`](backend/go-service/config/config.go) 新增 `MeetingConfig { HostGraceSeconds int; EmptyRoomTTLSeconds int; CleanupIntervalSeconds int; StaleRoomHours int }` - `config.dev.yaml` / `config.docker.yaml` 默认值:120 / 300 / 30 / 4 - `cmd/server/main.go` L82 后追加:`app.MeetingCleanupTask.Start()` + `defer Stop()` - `app/provider/provider.go`:`App` 结构体新增 `MeetingCleanupTask *task.MeetingCleanupTask` ### 3.5 DAO 扩展 - [`backend/go-service/app/meeting/dao/meeting_room_dao.go`](backend/go-service/app/meeting/dao/meeting_room_dao.go) - `ListStaleActive(ctx, hoursNoActivity int, limit int) ([]int64, error)`:`status != ended AND started_at < NOW() - hoursNoActivity` - `FindActiveByCode(ctx, code) (*MeetingRoom, error)`(若不存在直接复用已有 GetByCode) ### 3.6 Redis key 增补 | key | TTL | 值 | 作用 | |---|---|---|---| | `echo:meeting:host_grace:{code}` | 120s | `{host_id, grace_until, started_at}` JSON | 已在设计 §5.4 定义,Task 8 落地 | | `echo:meeting:empty_ttl:{code}` | 300s | `1`(占位) | 空房 TTL key(设计 §5.4 `echo:meeting:room:{code}` 的 TTL 语义,本 Task 改为独立 key 语义更清晰) | | `echo:meeting:grace_lock:{code}` | 5s | `1` | SETNX 锁,防止多实例部署并发处理同一过期事件(可选,MVP 单实例不强依赖) | ## 四、并发/容错要点 1. **host 重连与过期竞争**:`HandleHostGraceExpired` 入口先 `DEL host_grace` + 检查返回值;返回 0 即已被 `OnHostReconnect` 清走,跳过本次处理 2. **AfterFunc 取消**:`lifecycleSvc` 的 `sync.Map` 存 `*time.Timer`,`OnHostReconnect` / `CancelEmptyTTL` 时调 `timer.Stop()` + `sync.Map.Delete` 3. **服务重启兜底**:`RescheduleFromRedis` 扫 `SCAN echo:meeting:host_grace:*` 和 `echo:meeting:empty_ttl:*`,按 `PTTL` 剩余毫秒数重新 AfterFunc 4. **TransferHost 事务幂等**:DAO 层已是事务;外层在 DEL grace_key 成功后才调 TransferHost,天然串行 5. **4 小时兜底 EndRoom**:`cleanupTask` 用独立 context.Background + 5min timeout,失败仅日志不 panic ## 五、测试/验收 - 单元:psql 手工脚本验证 `TransferHost` 事务在冲突时回滚(复用 Task 3 风格) - E2E 脚本 [`docs/verify/meeting_t8_verify.mjs`](docs/verify/meeting_t8_verify.mjs)(TEST 模式下 `HostGraceSeconds=2`、`EmptyRoomTTLSeconds=3`、`CleanupIntervalSeconds=1`): 1. host 掉线 → 2s 内读 `host_grace` 存在 → 第二成员 3s 后收 `meeting.host.changed`,room.HostID 在 DB 已更新 2. host 掉线 → 1s 内重连 → `host_grace` 被 DEL → host 身份保留 3. 全员 leave → `empty_ttl` 存在 → 新用户 join → key 被 DEL + 房间 status 仍 Active 4. 全员 leave → 不 join → 3s 后 MarkEnded + CloseRouter(Node 日志印证) 5. JoinRoom 两次不再产生两次 `router created` Node 日志(Task 7 遗留修复验收) - `go build ./... && go vet ./...` 全绿,ReadLints 零问题 ## 六、文档同步(commit 前) - `docs/progress/CURRENT_STATUS.md` 新增 Task 8 章节,总进度切 `Task 0-8 ✅ / Task 9-16 待执行` - `docs/plans/2026-04-21-phase2e-2-implementation.plan.md` Task 8 标记已完成 + 实际产出 - `docs/plans/2026-04-21-phase2e-2-design.md` §十六变更记录追加 Task 8 偏差(若有) - `docs/api/frontend/meeting.md` 补充 host 宽限期 UI 建议 + 空房复活行为 - `.cursor/rules/project-context.mdc` 进度同步 ## 七、风险与回退 - 本地 timer 失效(宿主时钟跳变)→ 30s 扫描兜底接管 - 多实例部署并发处理同一过期事件 → MVP 单实例可容忍,后续用 `grace_lock` SETNX 修复 - JoinRoom 移除 CreateRouter 后如果 Router 丢失(Node 重启)→ HTTPMediaOrchestrator 层可加"404 → 重建"兜底(作为 Task 8 可选增强或 Task 16 再议)