初始化功能
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/nl-im-service.iml" filepath="$PROJECT_DIR$/.idea/nl-im-service.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/nl-im-service.iml
generated
Normal file
9
.idea/nl-im-service.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
128
cmd/server/main.go
Normal file
128
cmd/server/main.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"xk-websocket-v2/internal/api"
|
||||
"xk-websocket-v2/internal/manager"
|
||||
"xk-websocket-v2/internal/model"
|
||||
"xk-websocket-v2/internal/service"
|
||||
"xk-websocket-v2/internal/turnserver"
|
||||
"xk-websocket-v2/internal/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ... (initConfig, initDB, initRedis 保持不变) ...
|
||||
func initConfig() {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath("configs")
|
||||
viper.AddConfigPath(".")
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Fatalf("❌ 无法读取配置文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initDB() *gorm.DB {
|
||||
dsn := viper.GetString("database.dsn")
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ 数据库连接失败: %v", err)
|
||||
}
|
||||
db.AutoMigrate(&model.ChatMessage{})
|
||||
return db
|
||||
}
|
||||
|
||||
func initRedis() *redis.Client {
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: viper.GetString("redis.addr"),
|
||||
Password: viper.GetString("redis.password"),
|
||||
DB: viper.GetInt("redis.db"),
|
||||
})
|
||||
if _, err := rdb.Ping(rdb.Context()).Result(); err != nil {
|
||||
log.Fatalf("❌ Redis 连接失败: %v", err)
|
||||
}
|
||||
return rdb
|
||||
}
|
||||
|
||||
func main() {
|
||||
initConfig()
|
||||
db := initDB()
|
||||
rdb := initRedis()
|
||||
|
||||
ws.StartWorkerPool()
|
||||
defer ws.StopWorkerPool()
|
||||
service.InitChatService(db, rdb)
|
||||
go turnserver.Start()
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
// 允许跨域 (重要:为了前端本地开发)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-User-ID")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.GET("/ws", func(c *gin.Context) {
|
||||
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.Query("user_id")
|
||||
clientID := fmt.Sprintf("%s-%d", viper.GetString("app.node_id"), time.Now().UnixNano())
|
||||
client := &manager.Client{ID: clientID, Conn: conn, SendQueue: make(chan []byte, 256)}
|
||||
|
||||
manager.Manager.Register(client)
|
||||
if userID != "" {
|
||||
service.ChatSvc.BindUser(client, userID)
|
||||
}
|
||||
client.SendQueue <- []byte(fmt.Sprintf(`{"clientId": "%s"}`, clientID))
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
manager.Manager.Unregister(client)
|
||||
break
|
||||
}
|
||||
// WebSocket 仅处理信令和心跳,不再处理 send_message (改走API)
|
||||
// 但为了兼容,仍保留 PushTask
|
||||
ws.PushTask(client, message)
|
||||
}
|
||||
})
|
||||
|
||||
apiGroup := r.Group("/api")
|
||||
{
|
||||
apiGroup.POST("/send", api.SendHandler)
|
||||
apiGroup.POST("/send-to-user", api.SendToUserHandler)
|
||||
apiGroup.POST("/bind", api.BindHandler)
|
||||
apiGroup.GET("/check-user-online", api.CheckUserOnlineHandler)
|
||||
apiGroup.GET("/messages", api.HistoryHandler)
|
||||
apiGroup.GET("/messages/sync", api.SyncMessagesHandler)
|
||||
apiGroup.GET("/health", api.HealthHandler)
|
||||
apiGroup.GET("/ice-servers", api.ICEHandler)
|
||||
|
||||
// 新增:联系人列表
|
||||
apiGroup.GET("/contacts", api.ContactListHandler)
|
||||
}
|
||||
|
||||
port := viper.GetString("app.port")
|
||||
log.Printf("🚀 服务启动在端口: %s", port)
|
||||
r.Run(":" + port)
|
||||
}
|
||||
51
configs/config.yaml
Normal file
51
configs/config.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
# ==========================================
|
||||
# 应用基础配置
|
||||
# ==========================================
|
||||
app:
|
||||
name: "xk-websocket-v2"
|
||||
# 服务监听端口 (HTTP & WebSocket)
|
||||
port: "12080"
|
||||
# 节点唯一标识符 (集群模式下必须唯一,用于路由消息)
|
||||
node_id: "node-01"
|
||||
# 日志级别: debug, info, warn, error
|
||||
log_level: "debug"
|
||||
|
||||
# ==========================================
|
||||
# MySQL 数据库配置
|
||||
# ==========================================
|
||||
database:
|
||||
# 数据库连接字符串 (DSN)
|
||||
# 格式: user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
dsn: "root:root@tcp(127.0.0.1:3306)/nl_im?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
# 连接池最大空闲连接数
|
||||
max_idle_conns: 10
|
||||
# 连接池最大打开连接数
|
||||
max_open_conns: 100
|
||||
|
||||
# ==========================================
|
||||
# Redis 配置 (用于缓存和集群消息广播)
|
||||
# ==========================================
|
||||
redis:
|
||||
addr: "127.0.0.1:6379"
|
||||
password: "redis_pBjaRs"
|
||||
# 数据库索引 (0-15)
|
||||
db: 0
|
||||
|
||||
# ==========================================
|
||||
# TURN/STUN 服务器配置 (WebRTC 中继)
|
||||
# ==========================================
|
||||
turn:
|
||||
# 是否启用内置 TURN 服务
|
||||
enabled: true
|
||||
# 服务器公网 IP (重要:客户端必须能访问该IP)
|
||||
public_ip: "127.0.0.1"
|
||||
# TURN 服务监听端口 (UDP & TCP)
|
||||
listen_port: 3478
|
||||
# 认证领域名称
|
||||
realm: "xk-chat"
|
||||
# 共享密钥 (用于生成和校验临时凭证,分布式部署时所有节点必须一致)
|
||||
shared_secret: "xk-super-secret-key-2025"
|
||||
# UDP 端口范围下限
|
||||
min_port: 49152
|
||||
# UDP 端口范围上限
|
||||
max_port: 65535
|
||||
68
go.mod
Normal file
68
go.mod
Normal file
@@ -0,0 +1,68 @@
|
||||
module xk-websocket-v2
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/panjf2000/ants/v2 v2.11.3
|
||||
github.com/pion/turn/v2 v2.1.6
|
||||
github.com/spf13/viper v1.21.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.28.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pion/dtls/v2 v2.2.12 // indirect
|
||||
github.com/pion/logging v0.2.4 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/stun v0.6.1 // indirect
|
||||
github.com/pion/transport/v2 v2.2.10 // indirect
|
||||
github.com/pion/transport/v3 v3.1.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
222
go.sum
Normal file
222
go.sum
Normal file
@@ -0,0 +1,222 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg=
|
||||
github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
|
||||
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
|
||||
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
|
||||
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
|
||||
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
|
||||
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
|
||||
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
|
||||
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
|
||||
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
|
||||
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
|
||||
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
209
internal/api/handler.go
Normal file
209
internal/api/handler.go
Normal 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}})
|
||||
}
|
||||
225
internal/manager/client_manager.go
Normal file
225
internal/manager/client_manager.go
Normal 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
163
internal/model/types.go
Normal 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"
|
||||
)
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
120
internal/turnserver/server.go
Normal file
120
internal/turnserver/server.go
Normal 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
207
internal/ws/worker.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user