From e7f6a32dfe37360d76d1ee726ac8cc9e122540dd Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Tue, 21 Apr 2026 18:24:49 +0800 Subject: [PATCH] =?UTF-8?q?docs(phase2e-2):=20=E5=BD=92=E6=A1=A3=20Task=20?= =?UTF-8?q?8=20=E5=AE=9E=E6=96=BD=20plan=20=E6=96=87=E4=BB=B6=E8=87=B3=20d?= =?UTF-8?q?ocs/plans?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 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 --- ...26-04-21-phase2e-2-task8-lifecycle.plan.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 docs/plans/2026-04-21-phase2e-2-task8-lifecycle.plan.md diff --git a/docs/plans/2026-04-21-phase2e-2-task8-lifecycle.plan.md b/docs/plans/2026-04-21-phase2e-2-task8-lifecycle.plan.md new file mode 100644 index 0000000..c079c9f --- /dev/null +++ b/docs/plans/2026-04-21-phase2e-2-task8-lifecycle.plan.md @@ -0,0 +1,172 @@ +--- +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 再议)