房间号相关修复,视频’音频时长修复
This commit is contained in:
7
.env
7
.env
@@ -1,6 +1,6 @@
|
||||
# [Redis]
|
||||
REDIS_ADDR=127.0.0.1:6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_ADDR=127.0.0.1:63791
|
||||
REDIS_PASSWORD=xH5FRfKizaDpJR4H
|
||||
|
||||
# [Mysql]
|
||||
DB_DSN=root:root@tcp(localhost:3310)/z_xk?charset=utf8mb4&parseTime=True&loc=Local
|
||||
@@ -20,3 +20,6 @@ NODE_ID=ecs1
|
||||
# LOG_DIR=.\\ws-logs # 日志目录,默认 ./logs
|
||||
# LOG_ROTATE_FREQ=hourly # 轮转频率,可选: daily/hourly/10min
|
||||
LOG_PREFIX=ws-server # 日志文件前缀
|
||||
|
||||
OPEN_API_SECRET_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a
|
||||
OPEN_API_TIMESTAMP_TOLERANCE=300
|
||||
@@ -6,9 +6,14 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -18,12 +23,18 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-redis/redis/v8"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
"xk-websocket/models"
|
||||
"xk-websocket/utils"
|
||||
)
|
||||
|
||||
// JWT相关常量(与Laravel JWTService保持一致)
|
||||
const (
|
||||
AdminJWTSecretKey = "xk_admin_secret_key" // Admin JWT密钥
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
@@ -40,12 +51,16 @@ var upgrader = websocket.Upgrader{
|
||||
* - 读写锁 (ClientsMux) 保护连接池安全
|
||||
* - Redis 客户端 (RedisCli) 用于集群通信
|
||||
* - 数据库连接 (DB)
|
||||
* - Token验证相关 (AuthRedisCli, ClientInfoMap)
|
||||
*/
|
||||
type WebSocketController struct {
|
||||
Clients map[string]*websocket.Conn
|
||||
ClientsMux sync.RWMutex
|
||||
WriteMutex sync.Mutex // 添加写锁
|
||||
RedisCli *redis.Client
|
||||
AuthRedisCli *redis.Client // 用于Token验证的Redis客户端 (DB 1)
|
||||
ClientInfos map[string]*models.ClientInfo // 客户端认证信息映射
|
||||
ClientInfoMux sync.RWMutex // 客户端信息锁
|
||||
NodeID string
|
||||
Port string
|
||||
RedisCtx context.Context
|
||||
@@ -61,6 +76,7 @@ type WebSocketController struct {
|
||||
func NewWebSocketController(db *gorm.DB) *WebSocketController {
|
||||
return &WebSocketController{
|
||||
Clients: make(map[string]*websocket.Conn),
|
||||
ClientInfos: make(map[string]*models.ClientInfo),
|
||||
RedisCtx: context.Background(),
|
||||
DB: db, // 初始化数据库连接
|
||||
}
|
||||
@@ -147,10 +163,15 @@ func (c *WebSocketController) getLogWriter(logDir, freq string) *utils.DailyFile
|
||||
/**
|
||||
* InitRedisClient
|
||||
* 功能:建立 Redis 连接并验证连通性。
|
||||
* 初始化两个Redis客户端:
|
||||
* - RedisCli: DB 0,用于WebSocket集群通信
|
||||
* - AuthRedisCli: DB 1,用于Token验证(与Laravel保持一致)
|
||||
*/
|
||||
func (c *WebSocketController) InitRedisClient() {
|
||||
redisAddr := utils.GetEnv("REDIS_ADDR", "localhost:6379")
|
||||
redisPassword := utils.GetEnv("REDIS_PASSWORD", "")
|
||||
|
||||
// 初始化主Redis客户端 (DB 0) 用于集群通信
|
||||
c.RedisCli = redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: redisPassword,
|
||||
@@ -160,6 +181,19 @@ func (c *WebSocketController) InitRedisClient() {
|
||||
if err := c.checkRedisConnection(); err != nil {
|
||||
log.Fatalf("❌❌❌❌❌❌❌❌ Redis连接失败: %v", err)
|
||||
}
|
||||
|
||||
// 初始化Token验证Redis客户端 (DB 1) 用于Token验证
|
||||
c.AuthRedisCli = redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: redisPassword,
|
||||
DB: 1, // 与Laravel cache连接保持一致
|
||||
})
|
||||
|
||||
if err := c.checkAuthRedisConnection(); err != nil {
|
||||
log.Fatalf("❌❌❌❌❌❌❌❌ Token验证Redis连接失败: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Token验证Redis客户端初始化成功 (DB 1)")
|
||||
}
|
||||
|
||||
// 检查Redis连接
|
||||
@@ -168,6 +202,259 @@ func (c *WebSocketController) checkRedisConnection() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查Token验证Redis连接
|
||||
func (c *WebSocketController) checkAuthRedisConnection() error {
|
||||
_, err := c.AuthRedisCli.Ping(c.RedisCtx).Result()
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* ValidateToken
|
||||
* 功能:验证Token是否有效
|
||||
* 逻辑:从Redis DB 1中查询 xk_user_{token} 是否存在
|
||||
* @param token string 待验证的Token
|
||||
* @return (userInfo string, isValid bool) 用户信息JSON和验证结果
|
||||
*/
|
||||
func (c *WebSocketController) ValidateToken(token string) (string, bool) {
|
||||
if token == "" {
|
||||
log.Printf("⚠️ Token验证失败: Token为空")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 构造Redis Key: xk_user_{token}
|
||||
redisKey := models.TokenKeyPrefix + token
|
||||
|
||||
// 从Redis DB 1中查询
|
||||
userInfo, err := c.AuthRedisCli.Get(c.RedisCtx, redisKey).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
log.Printf("⚠️ Token验证失败: Token不存在或已过期 | Key=%s", redisKey)
|
||||
} else {
|
||||
log.Printf("❌ Token验证错误: %v | Key=%s", err, redisKey)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
log.Printf("✅ Token验证成功 | Key=%s", redisKey)
|
||||
return userInfo, true
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminJWTClaims
|
||||
* 结构体:Admin JWT Token的Claims结构
|
||||
* 与Laravel JWTService的payload结构保持一致
|
||||
*/
|
||||
type AdminJWTClaims struct {
|
||||
Data struct {
|
||||
ID int `json:"id"` // 用户ID
|
||||
} `json:"data"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
/**
|
||||
* ValidateAdminToken
|
||||
* 功能:验证Admin后台的JWT Token是否有效
|
||||
* 逻辑:
|
||||
* 1. JWT解码获取 payload.data.id (user_id)
|
||||
* 2. 用 xk_login_{user_id} 在 Redis DB 0 查询
|
||||
* @param token string 待验证的JWT Token
|
||||
* @return (userInfo string, isValid bool) 用户信息JSON和验证结果
|
||||
*/
|
||||
func (c *WebSocketController) ValidateAdminToken(token string) (string, bool) {
|
||||
if token == "" {
|
||||
log.Printf("⚠️ Admin Token验证失败: Token为空")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 解析JWT Token
|
||||
claims := &AdminJWTClaims{}
|
||||
parsedToken, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return []byte(AdminJWTSecretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Admin Token解析失败: %v | Token=%s", err, maskToken(token))
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !parsedToken.Valid {
|
||||
log.Printf("⚠️ Admin Token无效 | Token=%s", maskToken(token))
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 获取用户ID
|
||||
userID := claims.Data.ID
|
||||
if userID == 0 {
|
||||
log.Printf("⚠️ Admin Token验证失败: 无法获取用户ID | Token=%s", maskToken(token))
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 构造Redis Key: xk_login_{user_id}
|
||||
redisKey := fmt.Sprintf("%s%d", models.AdminTokenKeyPrefix, userID)
|
||||
|
||||
// 从Redis DB 0中查询(使用主Redis客户端)
|
||||
userInfo, err := c.RedisCli.Get(c.RedisCtx, redisKey).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
log.Printf("⚠️ Admin Token验证失败: 用户登录信息不存在或已过期 | Key=%s", redisKey)
|
||||
} else {
|
||||
log.Printf("❌ Admin Token验证错误: %v | Key=%s", err, redisKey)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
log.Printf("✅ Admin Token验证成功 | UserID=%d | Key=%s", userID, redisKey)
|
||||
return userInfo, true
|
||||
}
|
||||
|
||||
/**
|
||||
* ValidateTokenByUserType
|
||||
* 功能:根据用户类型选择对应的Token验证方法
|
||||
* @param token string 待验证的Token
|
||||
* @param userType string 用户类型 (doctor/user)
|
||||
* @return (userInfo string, isValid bool) 用户信息JSON和验证结果
|
||||
*/
|
||||
func (c *WebSocketController) ValidateTokenByUserType(token string, userType string) (string, bool) {
|
||||
if userType == models.UserTypeDoctor {
|
||||
// 后台用户(医生端PC):使用JWT Token验证
|
||||
log.Printf("🔐 使用Admin Token验证方式 | UserType=%s", userType)
|
||||
return c.ValidateAdminToken(token)
|
||||
}
|
||||
// 小程序用户:使用原始Token验证
|
||||
log.Printf("🔐 使用Mobile Token验证方式 | UserType=%s", userType)
|
||||
return c.ValidateToken(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* SaveClientInfo
|
||||
* 功能:保存客户端认证信息
|
||||
* @param clientID string 客户端ID
|
||||
* @param info *models.ClientInfo 客户端信息
|
||||
*/
|
||||
func (c *WebSocketController) SaveClientInfo(clientID string, info *models.ClientInfo) {
|
||||
c.ClientInfoMux.Lock()
|
||||
defer c.ClientInfoMux.Unlock()
|
||||
c.ClientInfos[clientID] = info
|
||||
}
|
||||
|
||||
/**
|
||||
* GetClientInfo
|
||||
* 功能:获取客户端认证信息
|
||||
* @param clientID string 客户端ID
|
||||
* @return *models.ClientInfo 客户端信息
|
||||
*/
|
||||
func (c *WebSocketController) GetClientInfo(clientID string) *models.ClientInfo {
|
||||
c.ClientInfoMux.RLock()
|
||||
defer c.ClientInfoMux.RUnlock()
|
||||
return c.ClientInfos[clientID]
|
||||
}
|
||||
|
||||
/**
|
||||
* RemoveClientInfo
|
||||
* 功能:移除客户端认证信息
|
||||
* @param clientID string 客户端ID
|
||||
*/
|
||||
func (c *WebSocketController) RemoveClientInfo(clientID string) {
|
||||
c.ClientInfoMux.Lock()
|
||||
defer c.ClientInfoMux.Unlock()
|
||||
delete(c.ClientInfos, clientID)
|
||||
}
|
||||
|
||||
/**
|
||||
* SendAuthResponse
|
||||
* 功能:向客户端发送认证响应
|
||||
* @param clientID string 客户端ID
|
||||
* @param status string 认证状态 (success/failed/token_expired)
|
||||
* @param message string 消息说明
|
||||
*/
|
||||
func (c *WebSocketController) SendAuthResponse(clientID string, status string, message string) {
|
||||
c.ClientsMux.RLock()
|
||||
conn, exists := c.Clients[clientID]
|
||||
c.ClientsMux.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
authResp := models.AuthResponse{
|
||||
AuthStatus: status,
|
||||
Message: message,
|
||||
ClientID: clientID,
|
||||
}
|
||||
|
||||
c.WriteMutex.Lock()
|
||||
defer c.WriteMutex.Unlock()
|
||||
|
||||
if err := conn.WriteJSON(authResp); err != nil {
|
||||
log.Printf("❌ 发送认证响应失败: %v | ClientID=%s", err, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maskToken
|
||||
* 功能:对Token进行脱敏处理,用于日志输出
|
||||
* @param token string 原始Token
|
||||
* @return string 脱敏后的Token
|
||||
*/
|
||||
func maskToken(token string) string {
|
||||
if len(token) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return token[:4] + "****" + token[len(token)-4:]
|
||||
}
|
||||
|
||||
/**
|
||||
* startTokenCheckTimer
|
||||
* 功能:启动定期Token检查定时器
|
||||
* 每5分钟检查一次Token是否仍然有效
|
||||
* 根据用户类型使用不同的验证方法
|
||||
* @param clientID string 客户端ID
|
||||
*/
|
||||
func (c *WebSocketController) startTokenCheckTimer(clientID string) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// 检查客户端是否仍然连接
|
||||
c.ClientsMux.RLock()
|
||||
_, exists := c.Clients[clientID]
|
||||
c.ClientsMux.RUnlock()
|
||||
|
||||
if !exists {
|
||||
log.Printf("🔌 客户端已断开,停止Token检查: ClientID=%s", clientID)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取客户端信息
|
||||
clientInfo := c.GetClientInfo(clientID)
|
||||
if clientInfo == nil || clientInfo.Token == "" {
|
||||
log.Printf("⚠️ 客户端信息不存在,停止Token检查: ClientID=%s", clientID)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据用户类型选择验证方法
|
||||
_, isValid := c.ValidateTokenByUserType(clientInfo.Token, clientInfo.UserType)
|
||||
if !isValid {
|
||||
log.Printf("⚠️ Token已过期,通知客户端重新认证: ClientID=%s | UserType=%s", clientID, clientInfo.UserType)
|
||||
c.SendAuthResponse(clientID, "token_expired", "Token已过期,请重新登录")
|
||||
|
||||
// 更新客户端认证状态
|
||||
clientInfo.IsAuth = false
|
||||
c.SaveClientInfo(clientID, clientInfo)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后检查时间
|
||||
clientInfo.LastCheckTime = time.Now()
|
||||
c.SaveClientInfo(clientID, clientInfo)
|
||||
log.Printf("✅ Token定期检查通过: ClientID=%s | UserType=%s", clientID, clientInfo.UserType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PrintStartupInfo
|
||||
* 功能:在控制台打印详细的服务启动信息,包括节点ID、端口、Redis状态及支持的消息类型。
|
||||
@@ -312,11 +599,50 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt
|
||||
log.Printf("📦📦📦📦📦📦📦📦 解析JSON消息成功: Type=%s", payload.RequestType)
|
||||
|
||||
if payload.RequestType == "bind" && payload.SenderUserID != "" {
|
||||
log.Printf("🔗🔗🔗🔗🔗🔗🔗🔗 处理绑定请求: \n ClientID=%s \n UserID=%s", senderID, payload.SenderUserID)
|
||||
log.Printf("🔗🔗🔗🔗🔗🔗🔗🔗 处理绑定请求: \n ClientID=%s \n UserID=%s \n UserType=%s \n Token=%s",
|
||||
senderID, payload.SenderUserID, payload.UserType, maskToken(payload.Token))
|
||||
|
||||
// 验证Token
|
||||
if payload.Token == "" {
|
||||
log.Printf("❌ 绑定失败: Token为空 | ClientID=%s", senderID)
|
||||
c.SendAuthResponse(senderID, "failed", "Token不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
// 根据用户类型选择验证方法
|
||||
userInfo, isValid := c.ValidateTokenByUserType(payload.Token, payload.UserType)
|
||||
if !isValid {
|
||||
log.Printf("❌ 绑定失败: Token验证失败 | ClientID=%s | UserType=%s", senderID, payload.UserType)
|
||||
c.SendAuthResponse(senderID, "failed", "Token验证失败,请重新登录")
|
||||
return
|
||||
}
|
||||
|
||||
// 绑定用户
|
||||
if err := c.bindClientToUser(senderID, payload.SenderUserID); err != nil {
|
||||
log.Printf("❌❌❌❌❌❌❌❌ 绑定失败: %v", err)
|
||||
c.SendAuthResponse(senderID, "failed", "绑定失败")
|
||||
} else {
|
||||
log.Printf("✅ 绑定成功: \n ClientID=%s \n UserID=%s", senderID, payload.SenderUserID)
|
||||
|
||||
// 保存客户端认证信息
|
||||
clientInfo := &models.ClientInfo{
|
||||
ClientID: senderID,
|
||||
UserID: payload.SenderUserID,
|
||||
UserType: payload.UserType,
|
||||
Token: payload.Token,
|
||||
IsAuth: true,
|
||||
AuthTime: time.Now(),
|
||||
LastCheckTime: time.Now(),
|
||||
}
|
||||
c.SaveClientInfo(senderID, clientInfo)
|
||||
|
||||
// 发送认证成功响应
|
||||
c.SendAuthResponse(senderID, "success", "认证成功")
|
||||
|
||||
// 启动定期Token检查
|
||||
go c.startTokenCheckTimer(senderID)
|
||||
|
||||
log.Printf("🔐 客户端认证信息已保存: \n ClientID=%s \n UserInfo=%s", senderID, userInfo)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -332,8 +658,8 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt
|
||||
SenderUserID: payload.SenderUserID,
|
||||
ReceiverUserID: payload.ReceiverUserID,
|
||||
MessageType: payload.MessageType,
|
||||
Content: payload.MessageContent,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: payload.MessageContent,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
c.sendMessageToClient(payload.TargetClientID, clientMsg)
|
||||
@@ -350,8 +676,8 @@ func (c *WebSocketController) handleClientMessage(senderID string, message []byt
|
||||
SenderUserID: payload.SenderUserID,
|
||||
ReceiverUserID: payload.ReceiverUserID,
|
||||
MessageType: payload.MessageType,
|
||||
Content: payload.MessageContent,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: payload.MessageContent,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
c.sendMessageToUser(payload.ReceiverUserID, clientMsg)
|
||||
@@ -431,8 +757,8 @@ func (c *WebSocketController) handleCallInvite(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "invite",
|
||||
}
|
||||
@@ -457,8 +783,8 @@ func (c *WebSocketController) handleCallAccept(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CallerID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "accepted",
|
||||
}
|
||||
@@ -483,8 +809,8 @@ func (c *WebSocketController) handleCallReject(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CallerID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "rejected",
|
||||
}
|
||||
@@ -509,8 +835,8 @@ func (c *WebSocketController) handleCallEnd(senderID, senderUserID string, signa
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "ended",
|
||||
}
|
||||
@@ -535,8 +861,8 @@ func (c *WebSocketController) handleCallOffer(senderID, senderUserID string, sig
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "offer",
|
||||
}
|
||||
@@ -561,8 +887,8 @@ func (c *WebSocketController) handleCallAnswer(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CallerID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "answer",
|
||||
}
|
||||
@@ -587,8 +913,8 @@ func (c *WebSocketController) handleCallCandidate(senderID, senderUserID string,
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "candidate",
|
||||
}
|
||||
@@ -613,8 +939,8 @@ func (c *WebSocketController) handleCallHangup(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "hangup",
|
||||
}
|
||||
@@ -639,8 +965,8 @@ func (c *WebSocketController) handleCallDisconnected(senderID, senderUserID stri
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "disconnected",
|
||||
}
|
||||
@@ -665,8 +991,8 @@ func (c *WebSocketController) handleCallTerminated(senderID, senderUserID string
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "terminated",
|
||||
}
|
||||
@@ -691,8 +1017,8 @@ func (c *WebSocketController) handleCallNoAnswer(senderID, senderUserID string,
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CallerID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "no-answer",
|
||||
}
|
||||
@@ -717,8 +1043,8 @@ func (c *WebSocketController) handleCallBusy(senderID, senderUserID string, sign
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CallerID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "busy",
|
||||
}
|
||||
@@ -743,8 +1069,8 @@ func (c *WebSocketController) handleCallFailed(senderID, senderUserID string, si
|
||||
SenderUserID: senderUserID,
|
||||
ReceiverUserID: signal.CalleeID,
|
||||
MessageType: signal.CallType,
|
||||
Content: signal.Data,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: signal.Data,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
CallID: signal.CallID,
|
||||
CallStatus: "failed",
|
||||
}
|
||||
@@ -789,8 +1115,8 @@ func (c *WebSocketController) SendMessageHandler(ctx *gin.Context) {
|
||||
SenderUserID: payload.SenderUserID,
|
||||
ReceiverUserID: payload.ReceiverUserID,
|
||||
MessageType: payload.MessageType,
|
||||
Content: payload.MessageContent,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: payload.MessageContent,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
var result string
|
||||
@@ -919,8 +1245,8 @@ func (c *WebSocketController) SendToUserHandler(ctx *gin.Context) {
|
||||
ReceiverUserID: payload.ReceiverUserID,
|
||||
Duration: payload.Duration,
|
||||
MessageType: payload.MessageType,
|
||||
Content: payload.MessageContent,
|
||||
SendTime: time.Now().Format(time.RFC3339),
|
||||
MessageContent: payload.MessageContent,
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// 如果是通话信令消息,添加通话相关字段
|
||||
@@ -1061,6 +1387,10 @@ func (c *WebSocketController) removeClient(clientID string) {
|
||||
|
||||
// 清理用户绑定关系
|
||||
c.cleanupUserBinding(clientID)
|
||||
|
||||
// 清理客户端认证信息
|
||||
c.RemoveClientInfo(clientID)
|
||||
log.Printf("🧹 已清理客户端认证信息: ClientID=%s", clientID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,7 +1484,7 @@ func (c *WebSocketController) sendMessageToClient(clientID string, message model
|
||||
}
|
||||
|
||||
log.Printf("✅ 消息已发送: \n ClientID=%s \n MsgType=%d \n Content=%s",
|
||||
clientID, message.MessageType, message.Content)
|
||||
clientID, message.MessageType, message.MessageContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1279,3 +1609,148 @@ func (c *WebSocketController) SubscribeToRedis() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPIAuthMiddleware
|
||||
* 功能:Open API HMAC 签名验证中间件
|
||||
* 用于保护 /open-api 分组下的接口
|
||||
*/
|
||||
func (c *WebSocketController) OpenAPIAuthMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// 1. 获取请求头中的签名信息
|
||||
timestamp := ctx.GetHeader("X-Timestamp")
|
||||
signature := ctx.GetHeader("X-Signature")
|
||||
|
||||
if timestamp == "" || signature == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "缺少签名信息"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 验证时间戳(防重放攻击,允许5分钟误差)
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "无效的时间戳"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tolerance := int64(utils.GetEnvInt("OPEN_API_TIMESTAMP_TOLERANCE", 300))
|
||||
now := time.Now().Unix()
|
||||
diff := now - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > tolerance {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "请求已过期"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 读取请求体
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// 重新设置请求体,供后续处理器使用
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
// 4. 计算签名
|
||||
secretKey := utils.GetEnv("OPEN_API_SECRET_KEY", "")
|
||||
if secretKey == "" {
|
||||
log.Printf("⚠️ OPEN_API_SECRET_KEY 未配置")
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "服务配置错误"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 签名算法: HMAC-SHA256(secret, timestamp + body)
|
||||
message := timestamp + string(body)
|
||||
h := hmac.New(sha256.New, []byte(secretKey))
|
||||
h.Write([]byte(message))
|
||||
expectedSignature := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// 5. 验证签名
|
||||
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
|
||||
log.Printf("❌ Open API 签名验证失败: expected=%s, got=%s", expectedSignature, signature)
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "签名验证失败"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ Open API 签名验证通过")
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APITokenAuthMiddleware
|
||||
* 功能:API Token 验证中间件
|
||||
* 用于客户端调用的接口,验证用户 Token
|
||||
* Token 从请求头 Authorization 获取
|
||||
* 用户类型从请求体 sender_user_id 推断(doctor- 开头为 doctor,否则为 user)
|
||||
*/
|
||||
func (c *WebSocketController) APITokenAuthMiddleware() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// 1. 获取 Token(从 Header 的 Authorization 字段)
|
||||
token := ctx.GetHeader("Authorization")
|
||||
if token == "" {
|
||||
token = ctx.GetHeader("authorization") // 兼容小写
|
||||
}
|
||||
|
||||
// 移除 Bearer 前缀
|
||||
if len(token) > 7 && (token[:7] == "Bearer " || token[:7] == "bearer ") {
|
||||
token = token[7:]
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
log.Printf("❌ API Token 验证失败: Token 为空")
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证信息"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 读取请求体获取 sender_user_id 以推断用户类型
|
||||
body, err := io.ReadAll(ctx.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ API Token 验证失败: 读取请求体失败")
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
// 重新设置请求体,供后续处理器使用
|
||||
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
// 3. 从请求体解析 sender_user_id
|
||||
var payload struct {
|
||||
SenderUserID string `json:"sender_user_id"`
|
||||
}
|
||||
userType := "user" // 默认为 user
|
||||
if err := json.Unmarshal(body, &payload); err == nil && payload.SenderUserID != "" {
|
||||
// 根据 sender_user_id 前缀判断用户类型
|
||||
if len(payload.SenderUserID) > 7 && payload.SenderUserID[:7] == "doctor-" {
|
||||
userType = "doctor"
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔐 API Token 验证: UserType=%s | SenderUserID=%s", userType, payload.SenderUserID)
|
||||
|
||||
// 4. 验证 Token
|
||||
userInfo, isValid := c.ValidateTokenByUserType(token, userType)
|
||||
if !isValid {
|
||||
log.Printf("❌ API Token 验证失败: Token 无效 | UserType=%s", userType)
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Token 验证失败,请重新登录"})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 将用户信息存入上下文,供后续处理器使用
|
||||
ctx.Set("userInfo", userInfo)
|
||||
ctx.Set("userType", userType)
|
||||
|
||||
log.Printf("✅ API Token 验证通过 | UserType=%s", userType)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -27,6 +27,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.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
|
||||
|
||||
2
go.sum
2
go.sum
@@ -37,6 +37,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
|
||||
@@ -17,10 +17,12 @@ type SendMessagePayload struct {
|
||||
TargetClientID string `json:"target_client_id"` // 目标客户端ID
|
||||
SenderUserID string `json:"sender_user_id"` // 发送者用户ID
|
||||
ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID
|
||||
MessageType int `json:"message_type"` // 消息类型(0-9)
|
||||
MessageType int `json:"message_type"` // 消息类型(0-13)
|
||||
MessageContent string `json:"message_content"` // 消息内容
|
||||
RoomId string `json:"room_id"` // 发送者用户ID
|
||||
RoomId string `json:"room_id"` // 房间ID
|
||||
Duration int `json:"duration"` // 时长(音频、视频、音视频通话)
|
||||
Token string `json:"token,omitempty"` // 认证Token
|
||||
UserType string `json:"user_type,omitempty"` // 用户类型: user/doctor
|
||||
// 通话专用字段
|
||||
CallID string `json:"call_id,omitempty"` // 通话唯一ID
|
||||
CallStatus string `json:"call_status,omitempty"` // 通话状态:invite/accepted/rejected/ended/candidate
|
||||
@@ -36,7 +38,7 @@ type SendToUserPayload struct {
|
||||
Duration int `json:"duration"` // 时长(音频、视频、音视频通话)
|
||||
SenderUserID string `json:"sender_user_id"` // 发送者用户ID
|
||||
ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID
|
||||
MessageType int `json:"message_type"` // 消息类型(0-9)
|
||||
MessageType int `json:"message_type"` // 消息类型(0-13)
|
||||
MessageContent string `json:"message_content"` // 消息内容
|
||||
// 通话专用字段
|
||||
CallID string `json:"call_id,omitempty"` // 通话唯一ID
|
||||
@@ -75,9 +77,9 @@ type ClientReceivedMessage struct {
|
||||
ReceiverID string `json:"receiver_id"` // 接收者连接ID
|
||||
SenderUserID string `json:"sender_user_id"` // 发送者用户ID
|
||||
ReceiverUserID string `json:"receiver_user_id"` // 接收者用户ID
|
||||
MessageType int `json:"message_type"` // 消息类型(0-9)
|
||||
Content string `json:"content"` // 消息内容
|
||||
SendTime string `json:"send_time"` // 发送时间
|
||||
MessageType int `json:"message_type"` // 消息类型(0-13)
|
||||
MessageContent string `json:"message_content"` // 消息内容
|
||||
CreatedAt string `json:"created_at"` // 创建时间
|
||||
Duration int `json:"duration"` // 时长(音频、视频、音视频通话)
|
||||
|
||||
// 通话专用字段
|
||||
@@ -101,6 +103,33 @@ type RedisMessage struct {
|
||||
type BindRequest struct {
|
||||
UserID string `json:"user_id"` // 用户ID
|
||||
ClientID string `json:"client_id"` // 客户端ID
|
||||
Token string `json:"token"` // 认证Token
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthResponse
|
||||
* 结构体:认证响应结构
|
||||
* 用途:返回给客户端的认证状态
|
||||
*/
|
||||
type AuthResponse struct {
|
||||
AuthStatus string `json:"auth_status"` // 认证状态: success/failed/token_expired
|
||||
Message string `json:"message"` // 消息说明
|
||||
ClientID string `json:"clientId,omitempty"` // 客户端ID
|
||||
}
|
||||
|
||||
/**
|
||||
* ClientInfo
|
||||
* 结构体:客户端连接信息(包含认证信息)
|
||||
* 用途:存储已连接客户端的认证状态和token
|
||||
*/
|
||||
type ClientInfo struct {
|
||||
ClientID string `json:"client_id"` // 客户端ID
|
||||
UserID string `json:"user_id"` // 用户ID
|
||||
UserType string `json:"user_type"` // 用户类型: user/doctor
|
||||
Token string `json:"token"` // 认证Token
|
||||
IsAuth bool `json:"is_auth"` // 是否已认证
|
||||
AuthTime time.Time `json:"auth_time"` // 认证时间
|
||||
LastCheckTime time.Time `json:"last_check_time"` // 上次检查时间
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,12 +184,25 @@ const (
|
||||
MessageTypeVideoCall = 6 // 视频通话
|
||||
MessageTypeAudioCall = 7 // 语音通话
|
||||
MessageTypeFile = 8 // 文件消息
|
||||
MessageTypeSystem = 9 // 系统消息
|
||||
MessageTypeRegister = 10 // 挂号信息
|
||||
MessageTypePatientExperience = 11 // 患者就诊经历卡片
|
||||
MessageTypeProductCard = 12 // 商品卡片
|
||||
MessageTypeEndConsultation = 13 // 结束问诊卡片
|
||||
)
|
||||
|
||||
// Redis键常量
|
||||
const (
|
||||
ClientUserKey = "client_user_mapping" // clientid -> userid映射
|
||||
UserClientKey = "user_client_mapping" // userid -> clientid列表
|
||||
TokenKeyPrefix = "xiaokang_database_xk_user_" // Mobile token存储前缀 (Redis DB 1) - 包含Laravel全局前缀
|
||||
AdminTokenKeyPrefix = "xiaokang_database_xk_login_" // Admin token存储前缀 (Redis DB 0) - 包含Laravel全局前缀
|
||||
)
|
||||
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeDoctor = "doctor" // 后台用户类型(PC管理端)
|
||||
UserTypeUser = "user" // 小程序用户类型
|
||||
)
|
||||
|
||||
// 响应结构
|
||||
|
||||
@@ -20,17 +20,29 @@ func SetupRoutes(router *gin.Engine, wsCtrl *controller.WebSocketController) {
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
router.GET("/ws", wsCtrl.HandleWebSocket)
|
||||
// API 分组路由
|
||||
|
||||
// 内部 API(公开接口,不需要验证)
|
||||
apiGroup := router.Group("/api")
|
||||
{
|
||||
apiGroup.POST("/send", wsCtrl.SendMessageHandler)
|
||||
apiGroup.POST("/bind", wsCtrl.BindHandler)
|
||||
apiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler)
|
||||
apiGroup.GET("/health", wsCtrl.HealthHandler)
|
||||
apiGroup.GET("/check-user-online", wsCtrl.IsUserOnline)
|
||||
|
||||
// 新增的消息路由
|
||||
apiGroup.GET("/messages", wsCtrl.GetMessagesHandler)
|
||||
apiGroup.GET("/messages/sync", wsCtrl.SyncMessagesHandler)
|
||||
}
|
||||
|
||||
// 客户端 API(需要 Token 验证)
|
||||
clientApiGroup := router.Group("/api")
|
||||
clientApiGroup.Use(wsCtrl.APITokenAuthMiddleware())
|
||||
{
|
||||
clientApiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler) // 客户端发送消息
|
||||
}
|
||||
|
||||
// Open API(需要 HMAC 签名验证)
|
||||
openApiGroup := router.Group("/open-api")
|
||||
openApiGroup.Use(wsCtrl.OpenAPIAuthMiddleware())
|
||||
{
|
||||
openApiGroup.POST("/send-to-user", wsCtrl.SendToUserHandler)
|
||||
}
|
||||
}
|
||||
|
||||
14
utils/env.go
14
utils/env.go
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetEnv(key, defaultValue string) string {
|
||||
@@ -18,3 +19,16 @@ func GetEnv(key, defaultValue string) string {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetEnvInt 获取整数类型的环境变量
|
||||
func GetEnvInt(key string, defaultVal int) int {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
intVal, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return intVal
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user