/** * package service * 作用:封装核心业务逻辑,包括数据库操作、Redis 消息分发、URL 抓取和多端同步。 */ package service import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "regexp" "strings" "time" "unicode/utf8" "xk-websocket-v2/internal/manager" "xk-websocket-v2/internal/model" "xk-websocket-v2/internal/turnserver" "github.com/go-redis/redis/v8" "github.com/spf13/viper" "gorm.io/gorm" ) // ChatService 业务服务结构体 type ChatService struct { DB *gorm.DB Redis *redis.Client } // ChatSvc 全局单例 var ChatSvc *ChatService /** * InitChatService * 功能:初始化聊天服务,并启动 Redis 集群消息订阅。 */ func InitChatService(db *gorm.DB, rdb *redis.Client) { ChatSvc = &ChatService{DB: db, Redis: rdb} // 启动 Redis 订阅监听协程 go ChatSvc.SubscribeClusterMessages() } /** * BindUser * 功能:处理用户绑定逻辑,更新本地连接管理器和 Redis 路由表。 * @param client 本地客户端连接 * @param userID 用户ID */ func (s *ChatService) BindUser(client *manager.Client, userID string) { // 1. 本地内存绑定 manager.Manager.BindUser(client.ID, userID) // 2. 更新 Redis 路由表:记录 UserID 所在的 NodeID // 用于 DispatchMessage 判断用户在哪个节点 nodeID := viper.GetString("app.node_id") ctx := context.Background() key := model.KeyUserNodeMap + userID // 设置 24 小时过期 s.Redis.Set(ctx, key, nodeID, 24*time.Hour) } /** * BindUserByClientID * 功能:根据 ClientID 查找连接并绑定用户(用于 HTTP 绑定接口)。 */ func (s *ChatService) BindUserByClientID(clientID, userID string) { manager.Manager.BindUser(clientID, userID) ctx := context.Background() key := model.KeyUserNodeMap + userID nodeID := viper.GetString("app.node_id") s.Redis.Set(ctx, key, nodeID, 24*time.Hour) } /** * IsUserOnline * 功能:检查用户是否在线 (查询 Redis)。 */ func (s *ChatService) IsUserOnline(userID string) bool { ctx := context.Background() key := model.KeyUserNodeMap + userID exists, _ := s.Redis.Exists(ctx, key).Result() return exists > 0 } /** * HandleUserMessage * 功能:处理用户发来的消息(入口函数)。包含多端同步、URL 抓取、持久化和转发逻辑。 * @param senderClient 发送消息的客户端(用于识别来源设备) * @param req 消息请求体 */ func (s *ChatService) HandleUserMessage(senderClient *manager.Client, req *model.SendMessageReq) { // 1. [关键] 拦截通话信令:处理多端同步逻辑 // 如果收到 "accepted" (接听) 信号,说明当前设备接听了电话 // 系统需要立即通知该用户的 *其他设备* 停止振铃并显示“已在其他设备接听” if req.CallStatus == "accepted" { s.NotifyOtherDevices(senderClient.UserID, senderClient.ID, req.CallID) } var extraData string = "" // 2. [新增] URL 识别与抓取逻辑 // 仅针对文本消息 (MessageType=0) if req.MessageType == 0 { url := extractURL(req.Content) if url != "" { meta := s.scrapeURL(url) if meta != nil { metaJson, _ := json.Marshal(meta) extraData = string(metaJson) log.Printf("🌐 https://www.toolsid.com/scrapers.html 抓取成功: %s", meta.Title) } } } // 3. 消息持久化 (MySQL) // WebRTC 的 Candidate/Offer/Answer 信令通常不需要持久化,只有聊天记录和通话记录需要 if req.MessageType != 6 && req.MessageType != 7 { // 假设 6,7 是纯信令流 msg := model.ChatMessage{ RoomID: req.RoomID, SenderUserID: senderClient.UserID, ReceiverUserID: req.ReceiverUserID, MessageType: req.MessageType, Content: req.Content, Extra: extraData, // 存入抓取到的元数据 Duration: req.Duration, CallID: req.CallID, CallStatus: req.CallStatus, } if err := s.DB.Create(&msg).Error; err != nil { log.Printf("❌ 消息持久化失败: %v", err) } // 4. 构建推送消息体 (DTO) // 将持久化后的完整对象推给前端 pushMsg := model.WsPayload{ RequestType: "receive_message", Data: msg, } msgBytes, _ := json.Marshal(pushMsg) // 分发消息 (路由到目标用户) s.DispatchMessage(req.ReceiverUserID, msgBytes) } else { // 信令消息直接透传,不存库 sigMsg := model.ChatMessage{ SenderUserID: senderClient.UserID, ReceiverUserID: req.ReceiverUserID, MessageType: req.MessageType, Content: req.Content, CallID: req.CallID, CallStatus: req.CallStatus, CreatedAt: time.Now(), } pushMsg := model.WsPayload{ RequestType: "receive_message", Data: sigMsg, } msgBytes, _ := json.Marshal(pushMsg) s.DispatchMessage(req.ReceiverUserID, msgBytes) } } // 辅助:正则提取第一个 URL func extractURL(text string) string { re := regexp.MustCompile(`https?://[^\s]+`) return re.FindString(text) } // 辅助:优化后的网页抓取 (Title, Desc, Image) // 解决乱码和图片路径问题 func (s *ChatService) scrapeURL(url string) *model.UrlMeta { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(url) if err != nil { return nil } defer resp.Body.Close() if resp.StatusCode != 200 { return nil } // 读取更多内容 (100KB) 以确保 meta 标签完整 buf := make([]byte, 100*1024) n, _ := io.ReadFull(resp.Body, buf) // 简单的编码修复:如果不是有效UTF-8,直接忽略或返回空(生产环境应用 charset decoder) html := string(buf[:n]) if !utf8.ValidString(html) { // 尝试作为 GBK 处理(这里简化处理,直接返回 nil 或 url 本身) return nil } meta := &model.UrlMeta{Url: url} // 优化正则:支持单双引号,支持换行 titleRe := regexp.MustCompile(`(?i)