/** * 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)(.*?)`) matches := titleRe.FindStringSubmatch(html) if len(matches) > 1 { meta.Title = strings.TrimSpace(matches[1]) } // 优化 og:image 正则 imgRe := regexp.MustCompile(`(?i) 1 { meta.Image = imgMatches[1] } // 优化 description 正则 descRe := regexp.MustCompile(`(?i) 1 { meta.Description = strings.TrimSpace(descMatches[1]) } if meta.Title == "" { return nil } // 相对路径处理 if meta.Image != "" && !strings.HasPrefix(meta.Image, "http") { // 简单拼接,不严谨但够用 if strings.HasPrefix(meta.Image, "/") { // 提取 domain domainRe := regexp.MustCompile(`(https?://[^/]+)`) domain := domainRe.FindString(url) meta.Image = domain + meta.Image } } return meta } /** * NotifyOtherDevices * 功能:通知同一用户的其他设备“电话已被接听”,实现多端协同。 */ func (s *ChatService) NotifyOtherDevices(userID, currentClientID, callID string) { // 获取其他在线设备ID otherClientIDs := manager.Manager.GetOtherClients(userID, currentClientID) if len(otherClientIDs) == 0 { return } log.Printf("📱 [多端同步] 用户 %s 在 %s 接听,通知其他 %d 个设备取消振铃", userID, currentClientID, len(otherClientIDs)) // 构建“已在其他设备接听”的信令 cancelMsg := model.WsPayload{ RequestType: "receive_message", Data: model.ChatMessage{ MessageType: 6, // 视频通话类型 CallStatus: "answered_elsewhere", // 特殊状态码 CallID: callID, Content: "通话已在其他设备接听", }, } bytes, _ := json.Marshal(cancelMsg) // 推送给其他设备 for _, cid := range otherClientIDs { manager.Manager.SendToClient(cid, bytes) } } /** * DispatchMessage * 功能:将消息路由到目标用户(无论其在哪个节点)。 * 逻辑:先查 Redis 看用户在哪个节点,如果是本机则直接发,否则通过 Pub/Sub 转发。 */ func (s *ChatService) DispatchMessage(targetUserID string, msgBytes []byte) { ctx := context.Background() // 1. 查询目标用户所在的节点ID nodeID, err := s.Redis.Get(ctx, model.KeyUserNodeMap+targetUserID).Result() currentNode := viper.GetString("app.node_id") // 用户不在线或 Redis 记录过期 if err == redis.Nil || nodeID == "" { return } // 2. 判断是否在本机 if nodeID == currentNode { // 在本机,直接投递 manager.Manager.SendToUser(targetUserID, msgBytes) } else { // 3. 不在本机,通过 Redis 广播给集群 clusterMsg := model.ClusterMessage{ SenderNodeID: currentNode, TargetUserID: targetUserID, Payload: msgBytes, } bytes, _ := json.Marshal(clusterMsg) s.Redis.Publish(ctx, model.ChanClusterBroadcast, bytes) } } /** * SubscribeClusterMessages * 功能:监听集群广播频道,处理来自其他节点的消息转发请求。 */ func (s *ChatService) SubscribeClusterMessages() { pubsub := s.Redis.Subscribe(context.Background(), model.ChanClusterBroadcast) defer pubsub.Close() ch := pubsub.Channel() currentNode := viper.GetString("app.node_id") for msg := range ch { var clusterMsg model.ClusterMessage if err := json.Unmarshal([]byte(msg.Payload), &clusterMsg); err != nil { continue } // 忽略来自自己的消息 if clusterMsg.SenderNodeID == currentNode { continue } // 修正:Payload 在 JSON 传输后变成了 interface{},需要重新序列化才能发给 WebSocket payloadBytes, _ := json.Marshal(clusterMsg.Payload) // 尝试发送给本地用户 manager.Manager.SendToUser(clusterMsg.TargetUserID, payloadBytes) } } /** * GetICEServers * 功能:获取 TURN 配置 (供 API 层调用) */ func (s *ChatService) GetICEServers(userID string) []model.ICEServerConfig { if !viper.GetBool("turn.enabled") { return []model.ICEServerConfig{} } ip := viper.GetString("turn.public_ip") port := viper.GetInt("turn.listen_port") username, credential := turnserver.GenerateCredentials(userID) return []model.ICEServerConfig{ { Urls: []string{fmt.Sprintf("turn:%s:%d", ip, port)}, Username: username, Credential: credential, }, } }