房间号相关修复,视频’音频时长修复

This commit is contained in:
2026-01-07 14:26:11 +08:00
parent 71cc36c7a5
commit d24e24b2fd
7 changed files with 627 additions and 78 deletions

7
.env
View File

@@ -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

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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" // 小程序用户类型
)
// 响应结构

View File

@@ -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)
}
}

View File

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