Files
EchoChat/docs/plans/2026-04-21-phase2e-2-task8-lifecycle.plan.md
bujinyuan e7f6a32dfe docs(phase2e-2): 归档 Task 8 实施 plan 文件至 docs/plans
将 Cursor Plan 模式下的临时产物 task_8_会议生命周期状态机_3a0bc37b.plan.md
归档至 docs/plans/ 目录,统一命名风格为日期前缀 + 阶段/任务描述:
2026-04-21-phase2e-2-task8-lifecycle.plan.md

用途:
- 与 docs/plans/2026-04-21-phase2e-2-{design,implementation.plan}.md 共同构成
  Phase 2e-2 Task 8 的完整设计 → 实施 → 归档三件套
- 保留 15 个 todo 的详细拆分 + 参考附录,作为后续复杂 Task 的实施样本
- 头部补充 archived_at / archived_note,指向实际交付结果与 commit

不改动文件主体内容,仅追加归档元数据。

Made-with: Cursor
2026-04-21 18:24:49 +08:00

173 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: Task 8 会议生命周期状态机
overview: 落地 Phase 2e-2 Task 8host 掉线 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 新增 MeetingConfigHostGrace/EmptyTTL/CleanupInterval/StaleRoomHours
status: completed
- id: t8-lifecycle
content: 新建 meeting_lifecycle_service.go5 个钩子 + sync.Map timer + RescheduleFromRedis
status: completed
- id: t8-dao
content: MeetingRoomDAO 新增 ListStaleActive2 行 SQL
status: completed
- id: t8-service-fix
content: meeting_service.goJoinRoom 移除 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.go30s 扰动过期 + 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.mjshost 宽限期转让 / 重连 / 空房 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 keyDEL 返回 0 视为已被重连清走,直接 return天然互斥
- **可测性**`config.MeetingConfig{HostGraceSeconds, EmptyRoomTTLSeconds, CleanupIntervalSeconds}`,默认 120/300/30E2E 测试可覆盖为 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 + CloseRouterNode 日志印证)
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 再议