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:
@@ -13,6 +13,7 @@ import (
|
||||
imHandler "github.com/echochat/backend/app/im/handler"
|
||||
meetingController "github.com/echochat/backend/app/meeting/controller"
|
||||
meetingService "github.com/echochat/backend/app/meeting/service"
|
||||
meetingTask "github.com/echochat/backend/app/meeting/task"
|
||||
notifyController "github.com/echochat/backend/app/notify/controller"
|
||||
notifyService "github.com/echochat/backend/app/notify/service"
|
||||
notifyTask "github.com/echochat/backend/app/notify/task"
|
||||
@@ -56,8 +57,10 @@ type App struct {
|
||||
NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务
|
||||
MeetingService *meetingService.MeetingService // 会议 REST 业务服务(Task 5 落地)
|
||||
MeetingSignalService *meetingService.MeetingSignalService // 会议 WS 信令业务服务(Task 6 落地)
|
||||
MeetingLifecycleSvc *meetingService.MeetingLifecycleService // 会议生命周期状态机(Task 8 落地)
|
||||
MeetingController *meetingController.MeetingController // 会议 REST 控制器
|
||||
MeetingWSHandler *meetingController.MeetingWSHandler // 会议 WS 事件 Handler(构造时自动注册路由到 Hub)
|
||||
MeetingCleanupTask *meetingTask.MeetingCleanupTask // 会议生命周期兜底定时任务(Task 8 落地)
|
||||
}
|
||||
|
||||
// NewApp 创建应用实例
|
||||
@@ -89,11 +92,14 @@ func NewApp(
|
||||
notifyCleanup *notifyTask.CleanupTask,
|
||||
meetingSvc *meetingService.MeetingService,
|
||||
meetingSignalSvc *meetingService.MeetingSignalService,
|
||||
meetingLifecycleSvc *meetingService.MeetingLifecycleService,
|
||||
meetingCtrl *meetingController.MeetingController,
|
||||
meetingWSHandler *meetingController.MeetingWSHandler,
|
||||
meetingCleanup *meetingTask.MeetingCleanupTask,
|
||||
) *App {
|
||||
wsHandler.SetOfflinePusher(offlinePusher)
|
||||
wsHandler.SetNotifyConnectHook(notifySvc)
|
||||
wsHandler.SetMeetingDisconnectHook(meetingSignalSvc)
|
||||
|
||||
return &App{
|
||||
Config: cfg,
|
||||
@@ -123,8 +129,10 @@ func NewApp(
|
||||
NotifyCleanupTask: notifyCleanup,
|
||||
MeetingService: meetingSvc,
|
||||
MeetingSignalService: meetingSignalSvc,
|
||||
MeetingLifecycleSvc: meetingLifecycleSvc,
|
||||
MeetingController: meetingCtrl,
|
||||
MeetingWSHandler: meetingWSHandler,
|
||||
MeetingCleanupTask: meetingCleanup,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
wire.Bind(new(meetingService.NotifyPusher), new(*notifyService.NotifyService)),
|
||||
wire.Bind(new(meetingService.UserInfoResolver), new(*contactDAO.FriendshipDAO)),
|
||||
wire.Bind(new(meetingService.OnlineChecker), new(*wsApp.OnlineService)),
|
||||
// Task 8:WS 断线钩子 → MeetingSignalService
|
||||
wire.Bind(new(wsApp.MeetingDisconnectHook), new(*meetingService.MeetingSignalService)),
|
||||
)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
controller7 "github.com/echochat/backend/app/meeting/controller"
|
||||
dao6 "github.com/echochat/backend/app/meeting/dao"
|
||||
service7 "github.com/echochat/backend/app/meeting/service"
|
||||
task2 "github.com/echochat/backend/app/meeting/task"
|
||||
controller6 "github.com/echochat/backend/app/notify/controller"
|
||||
dao5 "github.com/echochat/backend/app/notify/dao"
|
||||
service3 "github.com/echochat/backend/app/notify/service"
|
||||
@@ -103,10 +104,12 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
||||
meetingChatDAO := dao6.NewMeetingChatDAO(gormDB)
|
||||
meetingBroadcaster := service7.NewMeetingBroadcaster(meetingParticipantDAO, pubSub)
|
||||
httpMediaOrchestrator := service7.NewHTTPMediaOrchestrator(cfg)
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator)
|
||||
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator)
|
||||
meetingLifecycleService := service7.NewMeetingLifecycleService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, cfg)
|
||||
meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, meetingBroadcaster, notifyService, friendshipDAO, onlineService, httpMediaOrchestrator, meetingLifecycleService)
|
||||
meetingSignalService := service7.NewMeetingSignalService(meetingRoomDAO, meetingParticipantDAO, client, meetingBroadcaster, httpMediaOrchestrator, meetingLifecycleService)
|
||||
meetingController := controller7.NewMeetingController(meetingService)
|
||||
meetingWSHandler := controller7.NewMeetingWSHandler(meetingSignalService, hub)
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingSignalService, meetingController, meetingWSHandler)
|
||||
meetingCleanupTask := task2.NewMeetingCleanupTask(meetingLifecycleService, meetingRoomDAO, meetingChatDAO)
|
||||
app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingSignalService, meetingLifecycleService, meetingController, meetingWSHandler, meetingCleanupTask)
|
||||
return app, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user