/** * package main * * IM系统后端服务主程序 * * 功能概述: * 1. 初始化配置、数据库、Redis等基础服务 * 2. 初始化业务服务(认证、用户、联系人、房间、聊天、附件等) * 3. 启动WebSocket服务器和TURN服务器 * 4. 注册HTTP API路由和中间件 * 5. 启动HTTP服务器 * * 技术栈: * - Gin: HTTP Web框架 * - GORM: ORM数据库操作 * - Redis: 缓存和消息队列 * - WebSocket: 实时通信 * - JWT: 身份认证 */ package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "strings" "syscall" "time" "xk-websocket-v2/internal/api" "xk-websocket-v2/internal/manager" "xk-websocket-v2/internal/mediaserver" "xk-websocket-v2/internal/middleware" "xk-websocket-v2/internal/model" "xk-websocket-v2/internal/service" "xk-websocket-v2/internal/turnserver" "xk-websocket-v2/internal/utils" "xk-websocket-v2/internal/ws" "github.com/gin-gonic/gin" "github.com/go-redis/redis/v8" "github.com/gorilla/websocket" "github.com/spf13/viper" "gorm.io/driver/mysql" "gorm.io/gorm" ) // WebSocket 读端防护参数 // wsMaxMessageBytes: 单帧最大字节数,超过直接断开,防止超大帧耗尽内存 // wsReadWait: 读超时时间,须大于 writePump 的 Ping 周期(50s), // 收到 Pong 或业务消息时刷新;超时未收到任何数据则判定连接假死并回收 const ( wsMaxMessageBytes = 512 * 1024 wsReadWait = 90 * time.Second ) /** * initConfig * * 功能:初始化配置文件读取器 * * 步骤: * 1. 设置配置文件名为 "config" * 2. 设置配置文件类型为 YAML * 3. 添加配置文件搜索路径(configs目录和当前目录) * 4. 读取配置文件,如果失败则终止程序 */ // parseCSV 解析逗号分隔的字符串为去空白的切片(用于读取来源白名单等配置) func parseCSV(s string) []string { if strings.TrimSpace(s) == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if v := strings.TrimSpace(p); v != "" { out = append(out, v) } } return out } // isOriginAllowed 校验 WebSocket 来源(Origin)是否在白名单内;白名单为空时放行(仅建议本地开发) func isOriginAllowed(origin string, allowed []string) bool { if len(allowed) == 0 { return true } for _, a := range allowed { if a == origin { return true } } return false } func initConfig() { viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath("configs") viper.AddConfigPath(".") // 允许用环境变量覆盖配置中的敏感项(如 JWT_SECRET、DATABASE_DSN、REDIS_PASSWORD、TURN_SHARED_SECRET)。 // 生产环境应通过环境变量注入密钥/密码,config.yaml 仅保留本地开发默认值。 viper.AutomaticEnv() viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) if err := viper.ReadInConfig(); err != nil { log.Fatalf("❌ 无法读取配置文件: %v", err) } } // isProdEnv 判断是否为生产环境(app.env 为 production/prod), // 口径与 internal/api/auth_handler.go 的同名函数一致(包不同无法复用,各自维护4行)。 // 用途:数据库自动迁移只允许在开发环境执行,生产环境启动必须零 DDL。 func isProdEnv() bool { env := viper.GetString("app.env") return env == "production" || env == "prod" } /** * initDB * * 功能:初始化MySQL数据库连接并执行数据库迁移 * * 步骤: * 1. 从配置文件中读取数据库连接字符串(DSN) * 2. 使用GORM连接MySQL数据库 * 3. 如果连接失败,终止程序 * 4. 仅开发环境:执行 schema 自愈 + 自动迁移 + 表注释(生产环境启动零 DDL) * 5. 返回数据库连接实例 * * @returns *gorm.DB 数据库连接实例 */ func initDB() *gorm.DB { // 步骤1: 从配置文件读取数据库连接字符串 dsn := viper.GetString("database.dsn") // 步骤2: 使用GORM连接MySQL数据库 // DisableForeignKeyConstraintWhenMigrating: 禁止 AutoMigrate 自动创建外键约束。 // 原因:chat_conversations.target_id 既可能是用户ID也可能是群ID,历史上 GORM // 自动建的外键导致群会话插入失败,代码只能靠 SET FOREIGN_KEY_CHECKS=0 绕过(已移除)。 // 表结构与约束统一以 nl_im_plus.sql / migrations 下的 SQL 脚本为准。 db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ DisableForeignKeyConstraintWhenMigrating: true, }) if err != nil { log.Fatalf("❌ 数据库连接失败: %v", err) } // 步骤2.5: 应用连接池配置。 // config.yaml 中声明了 max_idle_conns/max_open_conns,但此前从未真正设置到 // 底层 sql.DB 上(配置形同虚设,走的是驱动默认值),高并发下连接数不可控 sqlDB, err := db.DB() if err != nil { log.Fatalf("❌ 获取底层数据库连接失败: %v", err) } maxIdleConns := viper.GetInt("database.max_idle_conns") if maxIdleConns <= 0 { maxIdleConns = 10 } maxOpenConns := viper.GetInt("database.max_open_conns") if maxOpenConns <= 0 { maxOpenConns = 100 } sqlDB.SetMaxIdleConns(maxIdleConns) sqlDB.SetMaxOpenConns(maxOpenConns) // 连接最长存活1小时:避免被 MySQL wait_timeout 掐断后拿到失效连接 sqlDB.SetConnMaxLifetime(time.Hour) // 步骤2.6: 环境闸门——数据库迁移(ensureSchema/AutoMigrate/表注释,均含 DDL)只在开发环境执行。 // 为什么:打包部署的生产环境启动绝不允许改表结构——多节点同时启动会并发迁移互相冲突、 // 大表 DDL 锁表可能拖垮线上服务、模型与线上库的历史差异还可能触发误改。 // 生产库结构变更统一由 DBA 手动执行 migrations/ 下的 SQL 脚本(01~07 均幂等可重复执行)。 if isProdEnv() { log.Println("🔒 生产环境跳过数据库自动迁移,表结构以 migrations/ SQL 脚本为准") return db } // 步骤2.7: schema 自愈(仅开发环境)——把 migrations/ 下需人工执行的结构变更在启动时幂等补齐。 // 为什么必须在 AutoMigrate 之前:moment_likes 的历史重复数据不先清理, // AutoMigrate 建 uk_moment_user 唯一索引会直接失败导致服务启不来 if err := ensureSchema(db); err != nil { log.Fatalf("❌ 数据库结构自愈失败: %v", err) } // 步骤3: 执行自动数据库迁移,创建所有表结构 err = db.AutoMigrate( &model.ChatMessage{}, &model.User{}, &model.UserContact{}, &model.ContactGroup{}, &model.ChatRoom{}, &model.ChatConversation{}, &model.FriendRequest{}, &model.VerificationCode{}, &model.Attachment{}, &model.ApiRequestLog{}, &model.LoginLog{}, &model.Moment{}, &model.MomentLike{}, &model.MomentComment{}, &model.MomentNotification{}, &model.MessageReadReceipt{}, &model.UserSetting{}, // AI 机器人两张表(等价 migrations/07):此前遗漏,漏执行脚本的开发库 /api/ai/* 会运行时报错; // users.is_bot 列已随 User 模型由上方 AutoMigrate 自动补齐 &model.AIConfig{}, &model.AIBot{}, ) if err != nil { log.Fatalf("❌ 数据库迁移失败: %v", err) } // 步骤4: 为所有表添加中文注释,提高数据库可读性 tableComments := []struct { table string comment string }{ {"chat_messages", "聊天消息表,持久化存储聊天记录,包括文本、多媒体和信令状态"}, {"users", "用户基本信息表"}, {"user_contacts", "用户联系人表,存储好友关系、分组、备注等信息"}, {"chat_conversations", "用户会话表,记录每个用户的最近会话列表和未读信息"}, {"contact_groups", "联系人分组表"}, {"chat_rooms", "聊天房间表,支持点对点和群聊"}, {"friend_requests", "好友申请表"}, {"verification_codes", "验证码表"}, {"attachments", "附件表,记录上传的文件信息"}, {"api_request_logs", "接口请求日志表"}, {"login_logs", "登录日志表"}, {"moments", "朋友圈动态表"}, {"moment_likes", "动态点赞表"}, {"moment_comments", "动态评论表"}, {"moment_notifications", "朋友圈通知表"}, {"message_read_receipts", "消息已读回执表"}, {"user_settings", "用户个性化设置表"}, {"room_members", "房间成员表,存储房间与用户的关联关系"}, {"message_deletions", "消息删除记录表,记录哪个用户删除了哪条消息(仅影响本人可见性)"}, {"ai_configs", "AI提供商配置表(全局一条,仅管理员维护)"}, {"ai_bots", "AI机器人定义表"}, } for _, tc := range tableComments { sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT = '%s'", tc.table, tc.comment) if err := db.Exec(sql).Error; err != nil { log.Printf("⚠️ 添加表注释失败 %s: %v", tc.table, err) } } log.Println("✅ 数据库迁移完成") return db } /** * ensureSchema * * 功能:启动时的数据库结构自愈——把 migrations/ 下需人工执行的变更以幂等 SQL 补齐。 * 注意:仅开发环境执行(initDB 里被 isProdEnv 闸门拦截),生产环境启动零 DDL, * 线上结构变更必须由 DBA 手动执行 migrations/ 脚本。 * * 为什么需要:项目约定表结构以 SQL 脚本为准(新表/新列不走 GORM 模型迁移), * 但脚本依赖人工执行,漏执行的开发库会出现"代码引用了不存在的表/列"的运行时错误, * 甚至 AutoMigrate 因存量脏数据建唯一索引失败而直接启动崩溃。 * 这里在启动路径上自动补齐,等价脚本(03/04/05)保留供 DBA 手动可控升级, * 已手动执行过的环境下各步骤经 information_schema / IF NOT EXISTS 判断后跳过。 * * 自愈内容: * 1. room_members 表(等价 migrations/02+03):建表含 nickname 列;老表缺列则补列 * 2. message_deletions 表(等价 migrations/04):删除仅对我生效的记录表 * 3. moment_likes 唯一索引(等价 migrations/05):先清理重复点赞、修正 like_count, * 再建 uk_moment_user——否则 AutoMigrate 在有重复数据的库上建索引必失败 */ func ensureSchema(db *gorm.DB) error { // ---- 1. room_members:建表(含 nickname)或给老表补 nickname 列 ---- // 不显式指定 COLLATE:沿用库默认排序规则,与 AutoMigrate 建的其他表保持一致, // 避免 JOIN users 等表时出现 "Illegal mix of collations" if err := db.Exec("CREATE TABLE IF NOT EXISTS `room_members` (" + "`room_id` varchar(100) NOT NULL COMMENT '房间ID'," + "`user_id` varchar(100) NOT NULL COMMENT '用户ID'," + "`role` tinyint(1) NOT NULL DEFAULT 0 COMMENT '成员角色(0=成员,1=管理员,2=群主)'," + "`joined_at` datetime(3) NULL DEFAULT NULL COMMENT '加入时间'," + "`muted_until` datetime NULL DEFAULT NULL COMMENT '禁言到期时间'," + "`nickname` varchar(100) NOT NULL DEFAULT '' COMMENT '群名片(群内显示昵称)'," + "PRIMARY KEY (`room_id`, `user_id`)," + "INDEX `idx_room_members_user_id`(`user_id`)" + ") ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '房间成员表,存储房间与用户的关联关系'").Error; err != nil { return fmt.Errorf("创建 room_members 表失败: %w", err) } // 表已存在的老库:检查 nickname 列,缺则补(MySQL 无 ADD COLUMN IF NOT EXISTS) var nickCount int64 if err := db.Raw("SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'room_members' AND COLUMN_NAME = 'nickname'"). Scan(&nickCount).Error; err != nil { return fmt.Errorf("检查 room_members.nickname 列失败: %w", err) } if nickCount == 0 { if err := db.Exec("ALTER TABLE `room_members` ADD COLUMN `nickname` varchar(100) NOT NULL DEFAULT '' " + "COMMENT '群名片(群内显示昵称)' AFTER `muted_until`").Error; err != nil { return fmt.Errorf("补充 room_members.nickname 列失败: %w", err) } log.Println("🔧 [Schema] room_members 已补充 nickname 列") } // ---- 2. message_deletions:消息删除记录表(删除仅对我生效) ---- if err := db.Exec("CREATE TABLE IF NOT EXISTS `message_deletions` (" + "`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键'," + "`message_id` bigint UNSIGNED NOT NULL COMMENT '被删除的消息ID'," + "`user_id` varchar(100) NOT NULL COMMENT '执行删除的用户ID(删除仅对该用户生效)'," + "`created_at` datetime(3) NULL DEFAULT NULL COMMENT '删除时间'," + "PRIMARY KEY (`id`)," + // 唯一索引防止同一用户对同一消息重复插入删除记录 "UNIQUE INDEX `uk_message_user`(`message_id`, `user_id`)," + "INDEX `idx_message_deletions_user_id`(`user_id`)" + ") ENGINE = InnoDB CHARACTER SET = utf8mb4 COMMENT = '消息删除记录表,记录哪个用户删除了哪条消息(仅影响本人可见性)'").Error; err != nil { return fmt.Errorf("创建 message_deletions 表失败: %w", err) } // ---- 3. moment_likes:清理重复点赞后补 uk_moment_user 唯一索引 ---- // 仅处理"表已存在且索引缺失"的存量库;全新环境表还不存在, // 跳过后由 AutoMigrate 直接建表+索引(空表无重复数据,安全) var likeTableCount int64 if err := db.Raw("SELECT COUNT(*) FROM information_schema.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'moment_likes'"). Scan(&likeTableCount).Error; err != nil { return fmt.Errorf("检查 moment_likes 表失败: %w", err) } if likeTableCount > 0 { var idxCount int64 if err := db.Raw("SELECT COUNT(*) FROM information_schema.STATISTICS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'moment_likes' AND INDEX_NAME = 'uk_moment_user'"). Scan(&idxCount).Error; err != nil { return fmt.Errorf("检查 moment_likes 唯一索引失败: %w", err) } if idxCount == 0 { // 3.1 清理历史重复数据(保留每组最早一条),否则建唯一索引会失败 if err := db.Exec("DELETE ml FROM `moment_likes` ml " + "INNER JOIN `moment_likes` ml2 ON ml.moment_id = ml2.moment_id " + "AND ml.user_id = ml2.user_id AND ml.id > ml2.id").Error; err != nil { return fmt.Errorf("清理 moment_likes 重复数据失败: %w", err) } // 3.2 修正因重复点赞虚高的 like_count(以实际点赞记录数为准) if err := db.Exec("UPDATE `moments` m SET m.like_count = " + "(SELECT COUNT(*) FROM `moment_likes` ml WHERE ml.moment_id = m.id)").Error; err != nil { return fmt.Errorf("修正 moments.like_count 失败: %w", err) } // 3.3 建唯一索引:并发点赞由数据库兜底,代码层把唯一冲突当"已点赞"幂等处理 if err := db.Exec("ALTER TABLE `moment_likes` ADD UNIQUE INDEX `uk_moment_user`(`moment_id`, `user_id`)").Error; err != nil { return fmt.Errorf("创建 moment_likes 唯一索引失败: %w", err) } log.Println("🔧 [Schema] moment_likes 已去重并补充 uk_moment_user 唯一索引") } } return nil } /** * initRedis * * 功能:初始化Redis连接 * * 步骤: * 1. 从配置文件读取Redis连接信息(地址、密码、数据库编号) * 2. 创建Redis客户端实例 * 3. 执行Ping操作测试连接 * 4. 如果连接失败,终止程序 * 5. 返回Redis客户端实例 * * @returns *redis.Client Redis客户端实例 */ func initRedis() *redis.Client { // 步骤1: 从配置文件读取Redis连接信息 rdb := redis.NewClient(&redis.Options{ Addr: viper.GetString("redis.addr"), // Redis服务器地址 Password: viper.GetString("redis.password"), // Redis密码 DB: viper.GetInt("redis.db"), // Redis数据库编号 }) // 步骤2: 执行Ping操作测试连接是否正常 if _, err := rdb.Ping(rdb.Context()).Result(); err != nil { log.Fatalf("❌ Redis 连接失败: %v", err) } return rdb } /** * main * * 功能:程序主入口,初始化所有服务并启动HTTP服务器 * * 执行流程: * 1. 初始化配置、数据库、Redis * 2. 启动WebSocket工作池 * 3. 初始化雪花ID生成器 * 4. 初始化所有业务服务 * 5. 启动TURN服务器(用于WebRTC) * 6. 创建Gin路由引擎 * 7. 注册中间件(响应时间、请求日志、CORS) * 8. 注册WebSocket路由 * 9. 注册HTTP API路由 * 10. 启动HTTP服务器 */ func main() { // 步骤1: 初始化基础服务 initConfig() // 读取配置文件 // 配置加载完成后再初始化 JWT 密钥,确保 config/env 中的 jwt.secret 真正生效 // (历史问题:utils 包级 init 早于配置读取,导致永远使用默认密钥) utils.InitJWT(viper.GetString("jwt.secret")) db := initDB() // 连接MySQL数据库 rdb := initRedis() // 连接Redis // 初始化 ip2region 离线 IP 库 ip2regionPath := viper.GetString("ip2region.db_path") if ip2regionPath == "" { ip2regionPath = "./ip2region.xdb" } if err := utils.InitIP2Region(ip2regionPath); err != nil { log.Printf("⚠️ ip2region 初始化失败: %v", err) } // 步骤2: 启动WebSocket工作池,用于处理WebSocket消息 ws.StartWorkerPool() defer ws.StopWorkerPool() // 程序退出时关闭工作池 // 步骤3: 初始化雪花ID生成器(用于生成全局唯一ID) // 从配置文件读取数据中心ID和机器ID,如果未配置则使用默认值 datacenterID := viper.GetInt64("snowflake.datacenter_id") if datacenterID == 0 { datacenterID = 1 // 默认数据中心ID为1 } machineID := viper.GetInt64("snowflake.machine_id") if machineID == 0 { machineID = 1 // 默认机器ID为1 } if err := utils.InitSnowflake(datacenterID, machineID); err != nil { log.Fatalf("❌ 初始化雪花ID生成器失败: %v", err) } // 步骤4: 初始化所有业务服务 service.InitChatService(db, rdb) // 聊天服务(消息处理、WebSocket分发) service.InitAuthService(db, rdb) // 认证服务(登录、注册、验证码) service.InitUserService(db) // 用户服务(用户信息管理) service.InitContactService(db) // 联系人服务(好友管理、分组管理) service.InitConversationService(db) // 会话服务(最近聊天列表) service.InitRoomService(db) // 房间服务(聊天房间管理) service.InitAttachmentService(db) // 附件服务(文件上传、管理) service.InitLoginLogService(db) // 登录日志服务(记录登录历史) service.InitMomentService(db) // 朋友圈服务(动态、点赞、评论) service.InitSearchService(db) // 搜索服务(聚合搜索) service.InitQRCodeLoginService(rdb) // 扫码登录服务(App 扫码登录 PC,Redis 状态机) service.InitAIBotService(db) // AI机器人服务(工厂模式接入大模型,机器人聊天应答) // 步骤5: 启动TURN服务器(用于WebRTC音视频通话) go turnserver.Start() // 步骤5.5: 启动媒体服务器(WebRTC SFU + RTMP) go mediaserver.Start() // 步骤6: 创建Gin路由引擎 r := gin.Default() // 步骤7: 注册中间件(按顺序执行) // 响应时间统计中间件(必须在最前面,用于记录请求开始时间) r.Use(middleware.ResponseTimeMiddleware()) // 接口请求日志中间件(记录所有API请求信息) requestLogMiddleware := middleware.NewRequestLogMiddleware(db) r.Use(requestLogMiddleware.Handler()) // CORS跨域中间件(允许前端跨域访问,重要:为了前端本地开发) r.Use(func(c *gin.Context) { // 设置允许的源(*表示允许所有源) c.Writer.Header().Set("Access-Control-Allow-Origin", "*") // 设置允许的HTTP方法(路由中大量使用 PUT/DELETE/PATCH,如删好友、改群设置, // 缺失会导致浏览器预检失败、相关功能在跨域场景下全部不可用) c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, PATCH, OPTIONS") // 设置允许的请求头(X-User-ID 已废弃:身份一律取自 JWT,不再信任该头) c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization") // 处理OPTIONS预检请求 if c.Request.Method == "OPTIONS" { c.AbortWithStatus(204) // 返回204 No Content return } c.Next() }) // 静态文件服务(提供附件访问功能) // 访问路径:/uploads/xxx -> ./uploads/xxx r.Static("/uploads", "./uploads") // PC 桌面端(Electron)更新包托管目录 // electron-updater 会到 /updates/latest.yml 检查版本,并下载同目录下的安装包与 .blockmap(增量更新) // 发版流程:electron-builder 打包后,把 release/ 下的 latest.yml、NL-IM-Setup-x.y.z.exe、.blockmap 上传到 ./updates r.Static("/updates", "./updates") // 步骤8: 注册WebSocket路由 // 路径:GET /ws?token=xxx(用户身份以 JWT 为准) // WebSocket 允许来源白名单(为空表示不限制,仅建议本地开发使用) wsAllowedOrigins := parseCSV(viper.GetString("app.ws_allowed_origins")) r.GET("/ws", func(c *gin.Context) { // 步骤1: 握手鉴权——必须携带有效 JWT(?token= 或 Authorization), // 用户身份以 Token 为准,禁止用 user_id 查询参数伪造身份(原实现无鉴权可冒充任意人) token := c.Query("token") if token == "" { token = c.GetHeader("Authorization") } if len(token) > 7 && token[:7] == "Bearer " { token = token[7:] } userID, err := utils.ValidateToken(token) if err != nil || userID == "" { // 统一响应结构:保持 HTTP 401(浏览器 WebSocket 握手失败只看状态码), // 响应体复用与业务接口一致的 ApiResponse 格式,不再返回裸 gin.H middleware.AbortUnauthorized(c, "WebSocket 认证失败") return } // 步骤2: 创建WebSocket升级器,按白名单校验来源(Origin) upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return isOriginAllowed(r.Header.Get("Origin"), wsAllowedOrigins) }} // 步骤3: 将HTTP连接升级为WebSocket连接 conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return // 升级失败,直接返回 } // 步骤3.1: 读端加固 // 1) SetReadLimit 限制单帧大小,防止恶意客户端发送超大帧一次性撑爆内存; // 2) SetReadDeadline + SetPongHandler 组成读超时机制:writePump 每 50s 发一次 Ping, // 正常客户端自动回 Pong 会刷新读截止时间;而"半开"连接(TCP 未断、应用层无响应) // 收不到任何数据/Pong,90s 后 ReadMessage 会超时返回,连接及其 goroutine/缓冲得以及时回收, // 避免假死连接长期堆积。 conn.SetReadLimit(wsMaxMessageBytes) _ = conn.SetReadDeadline(time.Now().Add(wsReadWait)) conn.SetPongHandler(func(string) error { return conn.SetReadDeadline(time.Now().Add(wsReadWait)) }) // 步骤4: 生成唯一的客户端ID(节点ID + 时间戳) clientID := fmt.Sprintf("%s-%d", viper.GetString("app.node_id"), time.Now().UnixNano()) // 步骤5: 创建客户端对象,包含连接和发送队列 client := &manager.Client{ID: clientID, RemoteIP: c.ClientIP(), Conn: conn, SendQueue: make(chan []byte, 256)} // 步骤6: 注册客户端到管理器 manager.Manager.Register(client) // 步骤7: 用 Token 中解析出的用户ID 绑定客户端(忽略查询参数中的 user_id) service.ChatSvc.BindUser(client, userID) // 步骤8: 发送客户端ID给前端 client.SendQueue <- []byte(fmt.Sprintf(`{"clientId": "%s"}`, clientID)) // 步骤9: 循环读取WebSocket消息 for { _, message, err := conn.ReadMessage() if err != nil { // 连接断开:注销客户端并清理 Redis 路由(该用户在本节点最后一个连接下线时) service.ChatSvc.HandleClientOffline(client) break } // 收到业务消息同样视为连接存活,刷新读截止时间 _ = conn.SetReadDeadline(time.Now().Add(wsReadWait)) // WebSocket 仅处理信令和心跳,不再处理 send_message (改走API) // 但为了兼容,仍保留 PushTask // 协程池满时 Invoke 会返回错误,记录日志避免消息静默丢失(无观测) if err := ws.PushTask(client, message); err != nil { log.Printf("⚠️ [WS] 任务入池失败(协程池可能已满): client=%s err=%v", client.ID, err) } } }) // WebSocket 推流路由(不经过 authGroup,避免中间件干扰 WebSocket 升级) // 路径:GET /api/call/ws-push?stream_id=xxx&user_id=xxx&room_id=xxx&token=xxx r.GET("/api/call/ws-push", api.WSPushHandler) // 步骤9: 注册HTTP API路由 apiGroup := r.Group("/api") { // 公开接口(不需要认证,任何人都可以访问) apiGroup.POST("/login", api.LoginHandler) apiGroup.POST("/register", api.RegisterHandler) apiGroup.POST("/send-email-code", api.SendEmailCodeHandler) apiGroup.POST("/send-sms-code", api.SendSmsCodeHandler) apiGroup.GET("/check-token", api.CheckTokenHandler) apiGroup.GET("/health", api.HealthHandler) // 扫码登录(PC 侧,未登录即可访问:生成二维码 + 轮询状态) apiGroup.POST("/qrcode/generate", api.GenerateQRCodeHandler) apiGroup.GET("/qrcode/status", api.QRCodeStatusHandler) // 需要认证的接口组(必须携带有效的JWT Token) authGroup := apiGroup.Group("") authGroup.Use(middleware.JWTAuthMiddleware()) // 使用JWT认证中间件 { // 聚合搜索 authGroup.GET("/search", api.GlobalSearchHandler) // 消息发送 / 绑定 / 历史(强制 JWT:发送者与绑定用户以 Token 为准,禁止伪造身份; // 历史消息仅房间成员可读)。原先放在公开组且靠 X-User-ID 兜底,存在严重越权风险,已收敛到此。 authGroup.POST("/send", api.SendHandler) authGroup.POST("/send-to-user", api.SendToUserHandler) authGroup.POST("/bind", api.BindHandler) authGroup.GET("/check-user-online", api.CheckUserOnlineHandler) authGroup.GET("/messages", api.HistoryHandler) authGroup.GET("/messages/sync", api.SyncMessagesHandler) // TURN/STUN 临时凭证:需登录,凭证绑定当前用户 authGroup.GET("/ice-servers", api.ICEHandler) // 用户管理 authGroup.GET("/user/my-info", api.GetMyInfoHandler) authGroup.GET("/user/list", api.GetUserListHandler) authGroup.GET("/user/:id", api.GetUserByIDHandler) authGroup.POST("/user/create", api.CreateUserHandler) authGroup.POST("/user/update", api.UpdateUserHandler) authGroup.POST("/user/delete", api.DeleteUserHandler) // 联系人管理 authGroup.GET("/contacts", api.ContactListHandler) authGroup.GET("/contacts/search", api.SearchUsersHandler) authGroup.POST("/contacts/add-friend", api.AddFriendHandler) authGroup.GET("/contacts/friend-requests", api.GetFriendRequestsHandler) authGroup.POST("/contacts/accept-request", api.AcceptFriendRequestHandler) authGroup.POST("/contacts/reject-request", api.RejectFriendRequestHandler) authGroup.GET("/contacts/groups", api.GetGroupsHandler) authGroup.POST("/contacts/groups", api.CreateGroupHandler) authGroup.POST("/contacts/groups/update/:id", api.UpdateGroupHandler) authGroup.POST("/contacts/groups/delete/:id", api.DeleteGroupHandler) authGroup.GET("/contacts/:id", api.GetContactDetailHandler) authGroup.POST("/contacts/update/:id", api.UpdateContactHandler) authGroup.POST("/contacts/delete/:id", api.DeleteContactHandler) // 消息撤回、删除、登出、已读回执 authGroup.POST("/messages/recall", api.RecallMessageHandler) authGroup.POST("/messages/delete", api.DeleteMessageHandler) authGroup.POST("/messages/read-receipts", api.MarkMessagesReadHandler) authGroup.POST("/logout", api.LogoutHandler) // 用户设置 authGroup.GET("/user/settings", api.GetUserSettingsHandler) authGroup.POST("/user/settings", api.UpdateUserSettingsHandler) // 会话管理 authGroup.GET("/conversations/by-room/:room_id", api.GetConversationByRoomHandler) authGroup.GET("/conversations", api.GetConversationListHandler) authGroup.POST("/conversations/reset-unread", api.ResetConversationUnreadHandler) authGroup.POST("/conversations/update", api.UpdateConversationHandler) authGroup.POST("/conversations/delete", api.DeleteConversationHandler) // 房间 / 群聊管理 authGroup.POST("/rooms", api.CreateRoomHandler) authGroup.GET("/rooms/:id", api.GetRoomHandler) // 群聊相关接口 authGroup.POST("/groups", api.CreateChatGroupHandler) authGroup.GET("/groups", api.ListUserGroupsHandler) // 获取用户群聊列表(必须在 /groups/:room_id 之前) authGroup.GET("/groups/:room_id", api.GetGroupInfoHandler) authGroup.GET("/groups/:room_id/members", api.ListGroupMembersHandler) authGroup.POST("/groups/:room_id/members", api.AddGroupMembersHandler) authGroup.POST("/groups/:room_id/members/:user_id/remove", api.RemoveGroupMemberHandler) authGroup.POST("/groups/:room_id/update", api.UpdateGroupInfoHandler) authGroup.POST("/groups/:room_id/members/:user_id/role", api.ChangeMemberRoleHandler) authGroup.POST("/groups/:room_id/quit", api.QuitGroupHandler) authGroup.POST("/groups/:room_id/dissolve", api.DissolveGroupHandler) authGroup.GET("/groups/:room_id/announcement", api.GetGroupAnnouncementHandler) authGroup.POST("/groups/:room_id/announcement", api.UpdateGroupAnnouncementHandler) authGroup.POST("/groups/:room_id/members/:user_id/mute", api.MuteGroupMemberHandler) authGroup.GET("/groups/:room_id/members/:user_id/mute", api.GetMemberMuteStatusHandler) authGroup.GET("/groups/:room_id/settings", api.GetGroupSettingsHandler) authGroup.POST("/groups/:room_id/settings", api.UpdateGroupSettingsHandler) authGroup.POST("/groups/:room_id/nickname", api.UpdateMyNicknameHandler) // 修改我在群里的昵称(群名片) authGroup.GET("/group-notifications", api.GetGroupNotificationsHandler) authGroup.GET("/calls/history", api.GetCallHistoryHandler) // 扫码登录(App 侧,需登录:扫码/确认/取消) authGroup.POST("/qrcode/scan", api.ScanQRCodeHandler) authGroup.POST("/qrcode/confirm", api.ConfirmQRCodeHandler) authGroup.POST("/qrcode/cancel", api.CancelQRCodeHandler) // AI 机器人:配置读写/测试/机器人增删改仅管理员(id=1),列表所有登录用户可见 authGroup.GET("/ai/config", api.GetAIConfigHandler) authGroup.POST("/ai/config", api.SaveAIConfigHandler) authGroup.POST("/ai/config/test", api.TestAIConfigHandler) authGroup.GET("/ai/bots", api.ListAIBotsHandler) authGroup.POST("/ai/bots", api.CreateAIBotHandler) authGroup.POST("/ai/bots/update/:id", api.UpdateAIBotHandler) authGroup.POST("/ai/bots/delete/:id", api.DeleteAIBotHandler) // 附件管理 authGroup.POST("/attachments/upload", api.UploadAttachmentHandler) authGroup.GET("/attachments", api.GetAttachmentsHandler) authGroup.GET("/attachments/:id", api.GetAttachmentHandler) authGroup.POST("/attachments/delete/:id", api.DeleteAttachmentHandler) // 朋友圈管理 api.RegisterMomentRoutes(authGroup) // 通话管理(音视频) api.RegisterCallRoutes(authGroup) // 媒体服务器管理 api.RegisterMediaRoutes(authGroup) } } // 步骤10: 启动HTTP服务器(支持优雅关闭) // 原实现 r.Run 阻塞至进程被信号强杀,defer 的清理逻辑(StopWorkerPool 等)不会执行, // MediaServer(SFU/RTMP/FFmpeg 子进程)、协程池、Redis/DB 连接都得不到有序释放, // 可能残留端口占用与孤儿 FFmpeg 进程。 // 改为 http.Server + 信号监听:收到 SIGINT/SIGTERM 后先停止接收新请求并限时等待存量请求, // 再依次关闭各组件,最后退出。 port := viper.GetString("app.port") // 从配置文件读取端口号 srv := &http.Server{ Addr: ":" + port, Handler: r, } // HTTP 服务放到独立协程启动,主协程留下来等退出信号 go func() { log.Printf("🚀 服务启动在端口: %s", port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("❌ HTTP 服务启动失败: %v", err) } }() // 阻塞等待 SIGINT(Ctrl+C) / SIGTERM(kill) 退出信号 quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit log.Println("🛑 收到退出信号,开始优雅关闭...") // 限时 10 秒等待存量 HTTP 请求处理完毕(超时则强制返回) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("⚠️ HTTP 服务关闭异常: %v", err) } // 有序释放各组件资源: // 1. 媒体服务器(关闭所有房间、SFU、RTMP 监听、FFmpeg 子进程) mediaserver.GetServer().Stop() // 2. WebSocket 协程池(defer 中也有一次,ants 的 Release 可重复调用,安全) ws.StopWorkerPool() // 3. Redis 连接 if err := rdb.Close(); err != nil { log.Printf("⚠️ Redis 关闭异常: %v", err) } // 4. MySQL 连接池 if sqlDB, err := db.DB(); err == nil { if err := sqlDB.Close(); err != nil { log.Printf("⚠️ MySQL 关闭异常: %v", err) } } log.Println("✅ 服务已优雅退出") }