修复了一些问题

This commit is contained in:
李琦
2026-08-24 15:29:53 +08:00
parent 3417856607
commit cb15328e5d
47 changed files with 5514 additions and 966 deletions

View File

@@ -20,9 +20,14 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"xk-websocket-v2/internal/api"
@@ -43,6 +48,15 @@ import (
"gorm.io/gorm"
)
// WebSocket 读端防护参数
// wsMaxMessageBytes: 单帧最大字节数,超过直接断开,防止超大帧耗尽内存
// wsReadWait: 读超时时间,须大于 writePump 的 Ping 周期(50s),
// 收到 Pong 或业务消息时刷新;超时未收到任何数据则判定连接假死并回收
const (
wsMaxMessageBytes = 512 * 1024
wsReadWait = 90 * time.Second
)
/**
* initConfig
*
@@ -54,16 +68,56 @@ import (
* 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
*
@@ -73,9 +127,8 @@ func initConfig() {
* 1. 从配置文件中读取数据库连接字符串(DSN)
* 2. 使用GORM连接MySQL数据库
* 3. 如果连接失败,终止程序
* 4. 执行自动数据库迁移,创建所有表结构
* 5. 为所有表添加中文注释
* 6. 返回数据库连接实例
* 4. 仅开发环境:执行 schema 自愈 + 自动迁移 + 表注释(生产环境启动零 DDL)
* 5. 返回数据库连接实例
*
* @returns *gorm.DB 数据库连接实例
*/
@@ -84,11 +137,53 @@ func initDB() *gorm.DB {
dsn := viper.GetString("database.dsn")
// 步骤2: 使用GORM连接MySQL数据库
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
// 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{},
@@ -108,6 +203,10 @@ func initDB() *gorm.DB {
&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)
@@ -135,6 +234,10 @@ func initDB() *gorm.DB {
{"moment_notifications", "朋友圈通知表"},
{"message_read_receipts", "消息已读回执表"},
{"user_settings", "用户个性化设置表"},
{"room_members", "房间成员表,存储房间与用户的关联关系"},
{"message_deletions", "消息删除记录表,记录哪个用户删除了哪条消息(仅影响本人可见性)"},
{"ai_configs", "AI提供商配置表(全局一条,仅管理员维护)"},
{"ai_bots", "AI机器人定义表"},
}
for _, tc := range tableComments {
@@ -148,6 +251,109 @@ func initDB() *gorm.DB {
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
*
@@ -197,7 +403,10 @@ func initRedis() *redis.Client {
*/
func main() {
// 步骤1: 初始化基础服务
initConfig() // 读取配置文件
initConfig() // 读取配置文件
// 配置加载完成后再初始化 JWT 密钥,确保 config/env 中的 jwt.secret 真正生效
// (历史问题:utils 包级 init 早于配置读取,导致永远使用默认密钥)
utils.InitJWT(viper.GetString("jwt.secret"))
db := initDB() // 连接MySQL数据库
rdb := initRedis() // 连接Redis
@@ -239,6 +448,8 @@ func main() {
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()
@@ -261,10 +472,11 @@ func main() {
r.Use(func(c *gin.Context) {
// 设置允许的源(*表示允许所有源)
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
// 设置允许的HTTP方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
// 设置允许的请求头
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-User-ID")
// 设置允许的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
@@ -277,20 +489,55 @@ func main() {
// 访问路径:/uploads/xxx -> ./uploads/xxx
r.Static("/uploads", "./uploads")
// 步骤8: 注册WebSocket路由
// 路径:GET /ws?user_id=xxx
r.GET("/ws", func(c *gin.Context) {
// 步骤1: 创建WebSocket升级器,允许所有来源连接
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
// 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")
// 步骤2: 将HTTP连接升级为WebSocket连接
// 步骤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: 获取用户ID(从查询参数中获取)
userID := c.Query("user_id")
// 步骤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())
@@ -301,10 +548,8 @@ func main() {
// 步骤6: 注册客户端到管理器
manager.Manager.Register(client)
// 步骤7: 如果提供了用户ID,绑定用户到客户端
if userID != "" {
service.ChatSvc.BindUser(client, userID)
}
// 步骤7: 用 Token 中解析出的用户ID 绑定客户端(忽略查询参数中的 user_id)
service.ChatSvc.BindUser(client, userID)
// 步骤8: 发送客户端ID给前端
client.SendQueue <- []byte(fmt.Sprintf(`{"clientId": "%s"}`, clientID))
@@ -313,13 +558,18 @@ func main() {
for {
_, message, err := conn.ReadMessage()
if err != nil {
// 连接断开,注销客户端
manager.Manager.Unregister(client)
// 连接断开:注销客户端并清理 Redis 路由(该用户在本节点最后一个连接下线时)
service.ChatSvc.HandleClientOffline(client)
break
}
// 收到业务消息同样视为连接存活,刷新读截止时间
_ = conn.SetReadDeadline(time.Now().Add(wsReadWait))
// WebSocket 仅处理信令和心跳,不再处理 send_message (改走API)
// 但为了兼容,仍保留 PushTask
ws.PushTask(client, message)
// 协程池满时 Invoke 会返回错误,记录日志避免消息静默丢失(无观测)
if err := ws.PushTask(client, message); err != nil {
log.Printf("⚠️ [WS] 任务入池失败(协程池可能已满): client=%s err=%v", client.ID, err)
}
}
})
@@ -337,15 +587,10 @@ func main() {
apiGroup.POST("/send-sms-code", api.SendSmsCodeHandler)
apiGroup.GET("/check-token", api.CheckTokenHandler)
apiGroup.GET("/health", api.HealthHandler)
apiGroup.GET("/ice-servers", api.ICEHandler)
// 消息相关(可选认证,兼容旧代码)
apiGroup.POST("/send", api.SendHandler)
apiGroup.POST("/send-to-user", api.SendToUserHandler)
apiGroup.POST("/bind", api.BindHandler)
apiGroup.GET("/check-user-online", api.CheckUserOnlineHandler)
apiGroup.GET("/messages", api.HistoryHandler)
apiGroup.GET("/messages/sync", api.SyncMessagesHandler)
// 扫码登录(PC 侧,未登录即可访问:生成二维码 + 轮询状态)
apiGroup.POST("/qrcode/generate", api.GenerateQRCodeHandler)
apiGroup.GET("/qrcode/status", api.QRCodeStatusHandler)
// 需要认证的接口组(必须携带有效的JWT Token)
authGroup := apiGroup.Group("")
@@ -354,6 +599,17 @@ func main() {
// 聚合搜索
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)
@@ -377,8 +633,9 @@ func main() {
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)
@@ -414,9 +671,24 @@ func main() {
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)
@@ -434,8 +706,53 @@ func main() {
}
}
// 步骤10: 启动HTTP服务器
// 步骤10: 启动HTTP服务器(支持优雅关闭)
// 原实现 r.Run 阻塞至进程被信号强杀,defer 的清理逻辑(StopWorkerPool 等)不会执行,
// MediaServer(SFU/RTMP/FFmpeg 子进程)、协程池、Redis/DB 连接都得不到有序释放,
// 可能残留端口占用与孤儿 FFmpeg 进程。
// 改为 http.Server + 信号监听:收到 SIGINT/SIGTERM 后先停止接收新请求并限时等待存量请求,
// 再依次关闭各组件,最后退出。
port := viper.GetString("app.port") // 从配置文件读取端口号
log.Printf("🚀 服务启动在端口: %s", port)
r.Run(":" + 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("✅ 服务已优雅退出")
}