初始化功能

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

209
internal/api/handler.go Normal file
View File

@@ -0,0 +1,209 @@
/**
* package api
* 作用:处理 HTTP 请求接口,包括消息发送、历史记录、系统状态检查等。
* 说明V2 版本中消息发送主要通过 HTTP 接口进行,而非 WebSocket 直接推送。
*/
package api
import (
"fmt"
"net/http"
"time"
"xk-websocket-v2/internal/manager"
"xk-websocket-v2/internal/model"
"xk-websocket-v2/internal/service"
"xk-websocket-v2/internal/turnserver"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
// ==========================================
// 消息发送相关接口
// ==========================================
/**
* SendHandler
* 功能:通用消息发送接口 (HTTP -> WebSocket)。
* 路径POST /api/send
* 逻辑:支持通过 target_client_id 或 receiver_user_id 发送。
* 重要:必须正确处理 SenderClientID防止多端同步时误通知发送端自己。
*/
func SendHandler(c *gin.Context) {
var req model.SendMessageReq
// 1. 绑定并校验 JSON 参数
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的JSON参数"})
return
}
// 2. 获取发送者 ID (从 Header 中获取,模拟鉴权)
// 生产环境应从 JWT Token 中解析 UserID
senderID := c.GetHeader("X-User-ID")
if senderID == "" {
senderID = "system" // 默认为系统消息
}
// 3. [关键修复] 获取发送端的 WebSocket ClientID
// 前端在调用此接口时,必须带上自己的 socket_client_id
// 如果前端没传 (兼容旧代码),则 fallback 到 api-gateway这会导致发送端自己也收到"其他设备接听"通知
clientID := req.SenderClientID
if clientID == "" {
clientID = "api-gateway"
}
// 4. 构造临时的发送者客户端对象
// 这个对象将传递给 Service 层,用于识别消息来源
mockClient := &manager.Client{
UserID: senderID,
ID: clientID,
}
// 5. 调用核心业务逻辑处理消息
service.ChatSvc.HandleUserMessage(mockClient, &req)
// 6. 返回成功响应
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
/**
* SendToUserHandler (V1 兼容)
* 功能:专门用于给指定用户发送消息。
* 路径POST /api/send-to-user
*/
func SendToUserHandler(c *gin.Context) {
// 复用 SendHandler因为 V2 的 SendHandler 已经支持 ReceiverUserID
SendHandler(c)
}
// ==========================================
// 用户与连接管理接口
// ==========================================
/**
* BindHandler
* 功能:手动绑定 ClientID 和 UserID。
* 路径POST /api/bind
* 场景:当 WebSocket 连接建立后,客户端通过 HTTP 接口补充用户信息。
*/
func BindHandler(c *gin.Context) {
var req model.BindReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
// 调用服务层进行绑定
service.ChatSvc.BindUserByClientID(req.ClientID, req.UserID)
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
/**
* CheckUserOnlineHandler
* 功能:检查用户是否在线。
* 路径GET /api/check-user-online
*/
func CheckUserOnlineHandler(c *gin.Context) {
userID := c.Query("user_id")
// 调用服务层查询 Redis
isOnline := service.ChatSvc.IsUserOnline(userID)
c.JSON(http.StatusOK, gin.H{"status": "success", "result": isOnline})
}
// ==========================================
// 数据查询接口
// ==========================================
/**
* HistoryHandler
* 功能:获取历史消息记录 (分页)。
* 路径GET /api/messages
*/
func HistoryHandler(c *gin.Context) {
roomID := c.Query("room_id")
var msgs []model.ChatMessage
// 简单查询最近 50 条
// 生产环境应添加 page, page_size 参数
result := service.ChatSvc.DB.Where("room_id = ?", roomID).
Order("created_at desc").Limit(50).Find(&msgs)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
c.JSON(http.StatusOK, gin.H{"data": msgs})
}
/**
* SyncMessagesHandler
* 功能:全量同步消息 (V1 兼容)。
* 路径GET /api/messages/sync
*/
func SyncMessagesHandler(c *gin.Context) {
HistoryHandler(c)
}
/**
* ContactListHandler
* 功能:获取联系人列表 (模拟数据)。
* 路径GET /api/contacts
* 说明:用于前端展示登录后的好友列表。
*/
func ContactListHandler(c *gin.Context) {
// 模拟 10 个用户数据
users := []model.UserContact{
{ID: "1001", Name: "张三 (我)", Avatar: "张", Desc: "Golang 专家"},
{ID: "1002", Name: "李琦 (妻)", Avatar: "李", Desc: "在线"},
{ID: "1003", Name: "王医生", Avatar: "医", Desc: "主任医师"},
{ID: "1004", Name: "客服小蜜", Avatar: "客", Desc: "全天在线"},
{ID: "1005", Name: "技术支持", Avatar: "技", Desc: "请重启试试"},
{ID: "1006", Name: "财务小赵", Avatar: "财", Desc: "报销单请提交"},
{ID: "1007", Name: "运维阿强", Avatar: "运", Desc: "服务器维护中"},
{ID: "1008", Name: "测试小丽", Avatar: "测", Desc: "Bug 太多了"},
{ID: "1009", Name: "HR", Avatar: "人", Desc: "本月考勤异常"},
{ID: "1010", Name: "老板", Avatar: "老", Desc: "今晚开会"},
}
c.JSON(http.StatusOK, gin.H{"data": users})
}
// ==========================================
// 系统与 WebRTC 接口
// ==========================================
/**
* HealthHandler
* 功能:服务健康检查。
* 路径GET /api/health
*/
func HealthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"node": viper.GetString("app.node_id"),
"time": time.Now().Format(time.RFC3339),
})
}
/**
* ICEHandler
* 功能:获取 TURN/STUN 服务器配置及临时凭证。
* 路径GET /api/ice-servers
* 用途WebRTC 前端在建立 PeerConnection 前需调用此接口。
*/
func ICEHandler(c *gin.Context) {
userID := c.Query("user_id")
// 生成临时凭证 (HMAC-SHA1)
username, credential := turnserver.GenerateCredentials(userID)
// 从配置读取公网IP和端口
ip := viper.GetString("turn.public_ip")
port := viper.GetInt("turn.listen_port")
// 构造配置返回给前端
cfg := model.ICEServerConfig{
Urls: []string{fmt.Sprintf("turn:%s:%d", ip, port)},
Username: username,
Credential: credential,
}
c.JSON(http.StatusOK, gin.H{"data": []model.ICEServerConfig{cfg}})
}

View File

@@ -0,0 +1,225 @@
/**
* package manager
* 作用:管理全量的 WebSocket 连接,提供线程安全的注册、注销、绑定和消息发送功能。
* 核心:使用 sync.Map 替代传统 map + RWMutex 以提高高并发下的读写性能。
*/
package manager
import (
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
/**
* Client
* 结构体:代表一个 WebSocket 客户端连接会话。
*/
type Client struct {
ID string // 客户端唯一标识 (格式: NodeID-Timestamp)
UserID string // 绑定的用户ID (未绑定时为空)
Conn *websocket.Conn // 底层 WebSocket 连接对象
SendQueue chan []byte // 发送缓冲队列 (防止网络阻塞导致写协程卡死)
}
/**
* ClientManager
* 结构体:连接管理器,维护 ClientID 映射和 UserID 反向索引。
*/
type ClientManager struct {
// 客户端列表: map[string]*Client (Key: ClientID)
clients sync.Map
// 用户反向索引: map[string][]string (Key: UserID, Value: [ClientID1, ClientID2])
userClients sync.Map
}
// Manager 全局单例
var Manager = &ClientManager{}
/**
* Register
* 功能:注册新建立的 WebSocket 连接。
* @param c *Client 客户端对象
*/
func (m *ClientManager) Register(c *Client) {
m.clients.Store(c.ID, c)
log.Printf("✅ [ClientManager] 客户端注册: %s", c.ID)
// 启动该客户端独享的写协程
go c.writePump()
}
/**
* Unregister
* 功能:注销连接,清理资源和索引。
* @param c *Client 客户端对象
*/
func (m *ClientManager) Unregister(c *Client) {
if _, ok := m.clients.Load(c.ID); ok {
m.clients.Delete(c.ID)
close(c.SendQueue) // 关闭通道,退出 writePump
c.Conn.Close()
// 如果已绑定用户,清理反向索引
if c.UserID != "" {
m.removeUserClient(c.UserID, c.ID)
}
log.Printf("🔌 [ClientManager] 客户端注销: %s", c.ID)
}
}
/**
* BindUser
* 功能:将 ClientID 与 UserID 进行绑定,支持多端登录。
* @param clientID 客户端ID
* @param userID 用户ID
*/
func (m *ClientManager) BindUser(clientID, userID string) {
clientInterface, ok := m.clients.Load(clientID)
if !ok {
return
}
client := clientInterface.(*Client)
client.UserID = userID
// 更新反向索引 (UserID -> []ClientID)
// 使用 LoadOrStore 初始化切片
actual, _ := m.userClients.LoadOrStore(userID, make([]string, 0))
ids := actual.([]string)
// 去重逻辑:防止重复添加同一个 ClientID
for _, id := range ids {
if id == clientID {
return
}
}
ids = append(ids, clientID)
m.userClients.Store(userID, ids)
log.Printf("🔗 [ClientManager] 绑定成功: %s -> %s", clientID, userID)
}
/**
* removeUserClient
* 功能:从用户的客户端列表中移除指定的 ClientID。
*/
func (m *ClientManager) removeUserClient(userID, clientID string) {
val, ok := m.userClients.Load(userID)
if !ok {
return
}
ids := val.([]string)
newIds := make([]string, 0)
for _, id := range ids {
if id != clientID {
newIds = append(newIds, id)
}
}
if len(newIds) == 0 {
m.userClients.Delete(userID) // 如果没有设备了,删除 Key
} else {
m.userClients.Store(userID, newIds) // 更新列表
}
}
/**
* SendToClient
* 功能:向指定客户端发送消息 (非阻塞模式)。
* @param clientID 目标客户端ID
* @param message 消息字节数据
*/
func (m *ClientManager) SendToClient(clientID string, message []byte) {
if val, ok := m.clients.Load(clientID); ok {
client := val.(*Client)
select {
case client.SendQueue <- message:
// 成功入队
default:
log.Printf("⚠️ [ClientManager] 发送队列已满,丢弃消息: %s", clientID)
}
}
}
/**
* SendToUser
* 功能:向指定用户的所有在线设备广播消息。
* @param userID 目标用户ID
* @param message 消息字节数据
*/
func (m *ClientManager) SendToUser(userID string, message []byte) {
if val, ok := m.userClients.Load(userID); ok {
ids := val.([]string)
for _, clientID := range ids {
m.SendToClient(clientID, message)
}
}
}
/**
* GetOtherClients
* 功能:获取该用户除 excludeClientID 以外的其他所有在线客户端ID。
* 场景:用于多端同步(如一端接听,通知其他端关闭振铃)。
*/
func (m *ClientManager) GetOtherClients(userID, excludeClientID string) []string {
var results []string
if val, ok := m.userClients.Load(userID); ok {
ids := val.([]string)
for _, id := range ids {
if id != excludeClientID {
results = append(results, id)
}
}
}
return results
}
/**
* writePump
* 功能:每个客户端独享的写协程,确保 WebSocket 并发写安全,并处理心跳 Ping。
*/
func (c *Client) writePump() {
ticker := time.NewTicker(50 * time.Second) // 心跳间隔
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.SendQueue:
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
// 队列关闭,发送 Close 帧
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.Conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
// 优化:如果队列里堆积了多条消息,一次性写完,减少系统调用次数
n := len(c.SendQueue)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'}) // JSON消息分隔符
w.Write(<-c.SendQueue)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
// 发送心跳 Ping
c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

163
internal/model/types.go Normal file
View File

@@ -0,0 +1,163 @@
/**
* package model
* 作用定义全系统通用的数据结构PO 与 DTO
* 规范:所有字段均采用 snake_case (下划线命名) 以保持 DB 和 JSON 的高度一致性。
*/
package model
import "time"
// ==========================================
// 数据库实体 (PO - Persistent Object)
// ==========================================
/**
* ChatMessage
* 对应数据库表chat_messages
* 作用:持久化存储聊天记录,包括文本、多媒体和信令状态。
*/
type ChatMessage struct {
// 消息唯一标识 ID
ID uint `gorm:"primaryKey;comment:消息唯一标识ID" json:"id"`
// 房间ID用于标识聊天室或会话
RoomID string `gorm:"type:varchar(100);index;comment:房间ID" json:"room_id"`
// 发送者用户ID
SenderUserID string `gorm:"type:varchar(100);index;comment:发送者用户ID" json:"sender_user_id"`
// 接收者用户ID
ReceiverUserID string `gorm:"type:varchar(100);index;comment:接收者用户ID" json:"receiver_user_id"`
// 消息类型 (0:文本, 1:图片, 2:音频, 3:视频, 4:处方, 5:病例, 6:视频通话, 7:语音通话, 8:文件)
MessageType int `gorm:"type:int;comment:消息类型" json:"message_type"`
// 消息内容或信令数据
Content string `gorm:"type:text;comment:消息内容" json:"content"`
// 存储富文本元数据如URL预览信息的JSON字符串: {"title":"...","image":"..."}
Extra string `gorm:"type:text;comment:扩展字段存储JSON格式的元数据" json:"extra"`
// 通话时长(秒),仅在通话类型的消息中有效
Duration int `gorm:"type:int;comment:通话时长(秒)" json:"duration"`
// 关联的通话ID (用于串联信令)
CallID string `gorm:"type:varchar(100);index;comment:关联的通话ID" json:"call_id"`
// 通话状态 (invite, accepted, ended, rejected, offer, answer, candidate, hangup, etc.)
CallStatus string `gorm:"type:varchar(50);comment:通话状态" json:"call_status"`
// 创建时间,自动生成
CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"`
}
// ==========================================
// 交互数据传输对象 (DTO - Data Transfer Object)
// ==========================================
/**
* WsPayload
* 作用WebSocket 传输的最外层通用载荷。
* 逻辑:根据 RequestType 字段决定如何解析 Data。
*/
type WsPayload struct {
// 请求类型: "bind", "heartbeat", "send_message", "receive_message"
RequestType string `json:"request_type"`
// 具体数据载荷,结构取决于 RequestType
Data interface{} `json:"data"`
}
/**
* SendMessageReq
* 作用:客户端发送消息或信令的请求参数结构。
*/
type SendMessageReq struct {
// [新增] 发送端的 WebSocket ClientID用于在多端同步时排除自己
SenderClientID string `json:"sender_client_id,omitempty"`
// 指定目标客户端ID (选填,用于点对点精确控制)
TargetClientID string `json:"target_client_id,omitempty"`
// 指定目标用户ID (选填,用于发送给用户的所有设备)
ReceiverUserID string `json:"receiver_user_id,omitempty"`
// 房间ID
RoomID string `json:"room_id"`
// 消息类型
MessageType int `json:"message_type"`
// 内容
Content string `json:"content"`
// 时长
Duration int `json:"duration"`
// WebRTC 信令专用字段: 通话唯一ID
CallID string `json:"call_id,omitempty"`
// WebRTC 信令专用字段: 信令状态 (invite, offer, answer, candidate, hangup)
CallStatus string `json:"call_status,omitempty"`
}
/**
* UrlMeta
* 作用URL 预览元数据结构 (存储在 Extra 字段中)
*/
type UrlMeta struct {
// 标题
Title string `json:"title"`
// 描述
Description string `json:"description"`
// 图片链接
Image string `json:"image"`
// 原始URL
Url string `json:"url"`
}
/**
* BindReq
* 作用:用户绑定请求参数。
*/
type BindReq struct {
// 用户ID
UserID string `json:"user_id"`
// WebSocket握手后获得的临时ID
ClientID string `json:"client_id"`
}
/**
* ICEServerConfig
* 作用:返回给前端的 TURN/STUN 配置信息。
*/
type ICEServerConfig struct {
// TURN服务器地址列表
Urls []string `json:"urls"`
// 鉴权用户名
Username string `json:"username,omitempty"`
// 鉴权密码
Credential string `json:"credential,omitempty"`
}
/**
* UserContact
* 作用:模拟联系人结构
*/
type UserContact struct {
// 用户唯一ID
ID string `json:"id"`
// 用户名称
Name string `json:"name"`
// 用户头像URL或字符
Avatar string `json:"avatar"`
// 用户描述或签名
Desc string `json:"desc"`
}
/**
* ClusterMessage
* 作用内部通信Redis Pub/Sub 集群消息转发结构。
*/
type ClusterMessage struct {
// 发出该消息的源节点ID
SenderNodeID string `json:"sender_node_id"`
// 目标用户ID
TargetUserID string `json:"target_user_id"`
// 原始消息体 (WsPayload)
Payload interface{} `json:"payload"`
}
// ==========================================
// 常量定义
// ==========================================
const (
// Redis Key 前缀: 用户所在节点映射 (Hash结构或String结构)
KeyUserNodeMap = "ws:user:node:"
// Redis Channel: 集群广播频道
ChanClusterBroadcast = "ws:cluster:broadcast"
)

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

View File

@@ -0,0 +1,120 @@
/**
* package turnserver
* 作用:基于 pion/turn 封装内置的 WebRTC 中继服务。
* 特性:
* 1. 支持 UDP 和 TCP 协议。
* 2. 手动实现了长期凭证机制 (Long-Term Credential Mechanism) 进行鉴权。
* 3. 支持分布式部署,通过共享密钥验证 Token。
*/
package turnserver
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"log"
"net"
"strconv"
"time"
"github.com/pion/turn/v2"
"github.com/spf13/viper"
)
/**
* Start
* 功能:初始化并启动 TURN 服务器。
* 注意:这是一个阻塞调用,通常建议在 goroutine 中运行。
*/
func Start() {
if !viper.GetBool("turn.enabled") {
log.Println(" [TURN] 服务已禁用")
return
}
publicIP := viper.GetString("turn.public_ip")
port := viper.GetInt("turn.listen_port")
realm := viper.GetString("turn.realm")
// 监听 UDP 端口
udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(port))
if err != nil {
log.Fatalf("❌ [TURN] UDP 监听失败: %v", err)
}
// 定义鉴权回调函数
// 每次客户端发起 Allocate 请求时触发
authHandler := func(username, realm string, srcAddr net.Addr) ([]byte, bool) {
// 1. (可选) 验证 username 时效性
// username 格式: timestamp:userid
// 生产环境可以解析 timestamp 检查是否过期
// 2. 根据共享密钥重新计算密码 (HMAC-SHA1)
secret := viper.GetString("turn.shared_secret")
password := generateHMACPassword(username, secret)
// 3. 生成 HA1 Key
// pion/turn 的 AuthHandler 需要返回 MD5(username:realm:password) 作为 Key
// 这是 STUN/TURN 标准的完整性校验要求
key := turn.GenerateAuthKey(username, realm, password)
return key, true
}
// 创建 TURN Server 实例
s, err := turn.NewServer(turn.ServerConfig{
Realm: realm,
AuthHandler: authHandler,
// 配置中继地址生成器 (使用公网IP否则客户端无法连接)
PacketConnConfigs: []turn.PacketConnConfig{
{
PacketConn: udpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorStatic{
RelayAddress: net.ParseIP(publicIP),
Address: "0.0.0.0",
},
},
},
})
if err != nil {
log.Fatalf("❌ [TURN] 启动失败: %v", err)
}
log.Printf("🚀 [TURN] 服务已启动 | IP: %s | Port: %d | Realm: %s", publicIP, port, realm)
// 阻塞保持服务运行
// 在 main.go 中,我们依靠主线程的 select 或 Web Server 阻塞,所以这里不需要 block
// 但为了代码独立性如果是独立进程需要阻塞。这里因为是库函数只需返回即可Server 会在后台运行。
_ = s
}
/**
* GenerateCredentials
* 功能:为特定用户生成 TURN 服务的访问凭证。
* 算法WebRTC 标准的 Long-Term Credential Mechanism (HMAC-SHA1)。
* @param userID 用户ID
* @return username, password (credential)
*/
func GenerateCredentials(userID string) (string, string) {
// Username = timestamp:userid (有效期 24 小时)
timestamp := time.Now().Add(24 * time.Hour).Unix()
username := fmt.Sprintf("%d:%s", timestamp, userID)
secret := viper.GetString("turn.shared_secret")
// 生成密码
password := generateHMACPassword(username, secret)
return username, password
}
/**
* generateHMACPassword
* 功能:辅助函数,使用 HMAC-SHA1 算法生成密码
* 标准Base64(HMAC-SHA1(secret, username))
*/
func generateHMACPassword(username, secret string) string {
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

207
internal/ws/worker.go Normal file
View File

@@ -0,0 +1,207 @@
/**
* package ws
* 作用:处理 WebSocket 协议层逻辑,并管理基于 ants 的协程工作池。
* 核心:将 I/O 读取与业务逻辑分离,防止海量连接导致 Goroutine 爆炸。
*/
package ws
import (
"encoding/json"
"fmt"
"log"
"runtime/debug"
"xk-websocket-v2/internal/manager"
"xk-websocket-v2/internal/model"
"xk-websocket-v2/internal/service"
"github.com/panjf2000/ants/v2"
)
// ==========================================
// 1. 配置常量与全局变量
// ==========================================
const (
// MaxWorkerPoolSize 定义最大的业务处理协程数
// 即使有 100万 个连接,同时也只有 5000 个协程在处理具体的业务逻辑(发消息、存库等)
// 其余连接处于空闲或等待 I/O 状态,极大节省内存。
MaxWorkerPoolSize = 5000
)
// GlobalWorkerPool 全局协程池实例
var GlobalWorkerPool *ants.PoolWithFunc
// ==========================================
// 2. 任务结构定义
// ==========================================
/**
* Job
* 结构体:代表一个具体的待处理任务
* 从 WebSocket 读到的原始 bytes 和对应的 Client 对象打包在一起
*/
type Job struct {
Client *manager.Client // 消息来源客户端
Payload []byte // 原始消息数据
}
// ==========================================
// 3. 初始化与启动
// ==========================================
/**
* StartWorkerPool
* 功能:初始化 ants 协程池
* 注意:必须在 main.go 中尽早调用
*/
func StartWorkerPool() {
var err error
// NewPoolWithFunc 创建一个带任务处理函数的池子
// 参数1: 池容量
// 参数2: 具体的任务处理函数 (消费者逻辑)
GlobalWorkerPool, err = ants.NewPoolWithFunc(MaxWorkerPoolSize, func(i interface{}) {
// 类型断言:将接口转换为具体的 Job 结构体
job, ok := i.(Job)
if !ok {
log.Printf("❌ [Worker] 类型断言失败")
return
}
// 执行业务逻辑
handleMessage(job.Client, job.Payload)
})
if err != nil {
log.Fatalf("❌ [Worker] 启动协程池失败: %v", err)
}
log.Printf("⚙️ [WorkerPool] 启动成功 | 容量: %d", MaxWorkerPoolSize)
}
/**
* StopWorkerPool
* 功能:优雅关闭协程池
*/
func StopWorkerPool() {
if GlobalWorkerPool != nil {
GlobalWorkerPool.Release()
log.Println("🛑 [WorkerPool] 已释放")
}
}
// ==========================================
// 4. 业务处理逻辑 (消费者)
// ==========================================
/**
* handleMessage
* 功能:核心业务路由,解析 JSON 并调用 Service 层
* 特性:包含 Panic Recover防止单个请求崩溃导致整个进程退出
*/
func handleMessage(client *manager.Client, message []byte) {
// 🛡️ Panic 恢复机制:这是生产环境必须的!
defer func() {
if r := recover(); r != nil {
log.Printf("❌ [Worker] Panic 捕获: %v\n堆栈信息: %s", r, debug.Stack())
// 可选:发送错误日志到监控系统 (Sentry/Prometheus)
}
}()
// 1. 基础格式校验
if len(message) == 0 {
return
}
// 2. 解析通用载荷 (RequestType)
var req model.WsPayload
if err := json.Unmarshal(message, &req); err != nil {
log.Printf("⚠️ [Worker] JSON解析失败: %v | 来源: %s", err, client.ID)
return
}
// 3. 业务路由分发
switch req.RequestType {
case "bind":
// 处理用户绑定 (bind)
// 格式: {"request_type": "bind", "data": {"user_id": "1001"}}
var bindData model.BindReq
if err := json.Unmarshal(jsonBytes(req.Data), &bindData); err == nil {
// 调用 Service 层逻辑
service.ChatSvc.BindUser(client, bindData.UserID)
} else {
log.Printf("⚠️ [Worker] 绑定参数错误: %v", err)
}
case "send_message":
// 处理消息发送 (send_message)
// 包含:文本聊天、图片、视频通话信令等
var msgData model.SendMessageReq
if err := json.Unmarshal(jsonBytes(req.Data), &msgData); err == nil {
// 核心:调用 ChatService 处理消息(持久化、转发、多端同步)
// 注意:这里传入的是 client 指针,以便 Service 层获取发送者的 UserID 和 ClientID
service.ChatSvc.HandleUserMessage(client, &msgData)
} else {
log.Printf("⚠️ [Worker] 消息参数错误: %v", err)
}
case "heartbeat":
// 处理心跳 (heartbeat)
// 快速响应,不需要走复杂业务逻辑
// 直接往客户端写回 Pong 消息
pong := []byte(`{"request_type":"heartbeat_ack"}`)
manager.Manager.SendToClient(client.ID, pong)
default:
log.Printf("❓ [Worker] 未知请求类型: %s", req.RequestType)
}
}
// ==========================================
// 5. 辅助工具
// ==========================================
/**
* jsonBytes
* 功能:辅助函数,将 map[string]interface{} 或其他类型转回 []byte
* 场景:因为 WsPayload.Data 是 interface{},在 Unmarshal 时可能被解析为 map需要二次转换
*/
func jsonBytes(v interface{}) []byte {
if v == nil {
return []byte("{}")
}
// 如果已经是 string直接转换
if str, ok := v.(string); ok {
return []byte(str)
}
// 否则重新序列化
b, _ := json.Marshal(v)
return b
}
/**
* PushTask
* 功能:对外暴露的投递函数,供 client_manager 或 main.go 调用
* 作用:非阻塞地将任务扔进池子
* @param client 消息来源客户端
* @param payload 原始消息
*/
func PushTask(client *manager.Client, payload []byte) error {
if GlobalWorkerPool == nil {
return fmt.Errorf("worker pool not initialized")
}
// 包装任务
job := Job{
Client: client,
Payload: payload,
}
// Invoke 会从池里取一个 worker 来执行 NewPoolWithFunc 注册的函数
// 如果池满了Invoke 会阻塞,直到有 worker 空闲 (或者返回错误,取决于配置)
return GlobalWorkerPool.Invoke(job)
}