Files
EchoChat/backend/go-service/cmd/server/main.go
bujinyuan 3b83c79036 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
2026-04-21 18:21:04 +08:00

119 lines
3.1 KiB
Go
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.

// 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", "服务已停止")
}