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
This commit is contained in:
bujinyuan
2026-04-21 18:24:49 +08:00
parent 3b83c79036
commit e7f6a32dfe

View File

@@ -0,0 +1,172 @@
---
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 再议