核心交付:
- 新建 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
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
// Package main 是 EchoChat 后端服务的入口
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"os/signal"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/echochat/backend/app/im/model"
|
||
"github.com/echochat/backend/app/provider"
|
||
"github.com/echochat/backend/config"
|
||
"github.com/echochat/backend/pkg/logs"
|
||
"github.com/echochat/backend/pkg/middleware"
|
||
"github.com/echochat/backend/router"
|
||
"github.com/gin-gonic/gin"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
func main() {
|
||
// 1. 加载配置(通过 CONFIG_NAME 环境变量指定配置文件名,默认 config.dev)
|
||
configName := os.Getenv("CONFIG_NAME")
|
||
if configName == "" {
|
||
configName = "config.dev"
|
||
}
|
||
cfg, err := config.Load("config", configName)
|
||
if err != nil {
|
||
fmt.Printf("加载配置失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
// 2. 初始化日志系统
|
||
if err := logs.Init(&cfg.Log); err != nil {
|
||
fmt.Printf("初始化日志失败: %v\n", err)
|
||
os.Exit(1)
|
||
}
|
||
defer logs.Sync()
|
||
|
||
ctx := context.Background()
|
||
logs.Info(ctx, "main", "EchoChat 服务启动中",
|
||
zap.String("mode", cfg.Server.Mode),
|
||
zap.Int("port", cfg.Server.Port),
|
||
)
|
||
|
||
// 3. 通过 Wire 初始化所有组件
|
||
app, err := provider.InitializeApp(cfg)
|
||
if err != nil {
|
||
logs.Fatal(ctx, "main", "初始化应用失败", zap.Error(err))
|
||
}
|
||
|
||
// 4. IM 数据库表自动迁移(开发阶段使用,生产环境配合 init.sql)
|
||
if err := app.DB.AutoMigrate(
|
||
&model.Conversation{},
|
||
&model.ConversationMember{},
|
||
&model.Message{},
|
||
); err != nil {
|
||
logs.Fatal(ctx, "main", "IM 表迁移失败", zap.Error(err))
|
||
}
|
||
logs.Info(ctx, "main", "IM 表迁移完成")
|
||
|
||
// 5. 创建 Gin Engine
|
||
if cfg.Server.Mode == "release" {
|
||
gin.SetMode(gin.ReleaseMode)
|
||
}
|
||
engine := gin.New()
|
||
|
||
// 6. 注册中间件(顺序:Trace → Logger → CORS → Recovery)
|
||
engine.Use(
|
||
middleware.Trace(),
|
||
middleware.Logger(),
|
||
middleware.CORS(),
|
||
middleware.Recovery(),
|
||
)
|
||
|
||
// 7. 注册路由(由 router.Setup 统一汇总各模块路由)
|
||
router.Setup(engine, app)
|
||
|
||
// 7.1 启动 notify 模块过期通知清理定时任务(后台周期执行)
|
||
app.NotifyCleanupTask.Start()
|
||
defer app.NotifyCleanupTask.Stop()
|
||
|
||
// 7.2 启动 meeting 模块生命周期兜底任务(Phase 2e-2 Task 8)
|
||
// 内部会先 RescheduleFromRedis 恢复服务重启前残留的 host_grace / empty_ttl timer
|
||
app.MeetingCleanupTask.Start()
|
||
defer app.MeetingCleanupTask.Stop()
|
||
|
||
// 8. 启动 HTTP 服务(优雅关闭)
|
||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||
srv := &http.Server{
|
||
Addr: addr,
|
||
Handler: engine,
|
||
}
|
||
|
||
go func() {
|
||
logs.Info(ctx, "main", "HTTP 服务启动", zap.String("addr", addr))
|
||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||
logs.Fatal(ctx, "main", "HTTP 服务启动失败", zap.Error(err))
|
||
}
|
||
}()
|
||
|
||
// 等待中断信号,优雅关闭
|
||
quit := make(chan os.Signal, 1)
|
||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||
<-quit
|
||
|
||
logs.Info(ctx, "main", "正在关闭服务...")
|
||
|
||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
defer cancel()
|
||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
logs.Error(ctx, "main", "服务关闭失败", zap.Error(err))
|
||
}
|
||
|
||
logs.Info(ctx, "main", "服务已停止")
|
||
}
|