初始化功能
This commit is contained in:
355
internal/service/chat_service.go
Normal file
355
internal/service/chat_service.go
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user