初始化功能

This commit is contained in:
2025-12-02 21:00:26 +08:00
commit 3eee37fa55
13 changed files with 1773 additions and 0 deletions

View 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,
},
}
}