Files
nl-im-service/internal/service/chat_service.go
2025-12-02 21:00:26 +08:00

356 lines
10 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 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)<title>(.*?)</title>`)
matches := titleRe.FindStringSubmatch(html)
if len(matches) > 1 {
meta.Title = strings.TrimSpace(matches[1])
}
// 优化 og:image 正则
imgRe := regexp.MustCompile(`(?i)<meta\s+property=["']og:image["']\s+content=["'](.*?)["']`)
imgMatches := imgRe.FindStringSubmatch(html)
if len(imgMatches) > 1 {
meta.Image = imgMatches[1]
}
// 优化 description 正则
descRe := regexp.MustCompile(`(?i)<meta\s+name=["']description["']\s+content=["'](.*?)["']`)
descMatches := descRe.FindStringSubmatch(html)
if len(descMatches) > 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,
},
}
}