Files
soul-yongping/soul-api/internal/handler/ws.go

150 lines
3.9 KiB
Go
Raw Normal View History

// Package handler - WebSocket 占位:用户在线检测
// 小程序连接 WSS 发心跳Redis 记录在线;管理端通过 HTTP 获取在线人数
// 后续可扩展:管理端 WSS 订阅、消息推送等
package handler
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/redis"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const (
wsOnlinePrefix = "user:online:"
wsOfflineTimeout = 300 // 5 分钟无心跳视为离线(秒)
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WsMiniprogram 处理小程序 WSS 连接:鉴权后记录心跳,维持在线状态
// 路径GET /ws/miniprogram?token=xxx
// 首条消息需包含 {"type":"auth","userId":"user_xxx"},占位阶段不校验 token
// 容错panic 时 recover 并关闭连接,不影响 HTTP API 及其他请求
func WsMiniprogram(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("[WS] WsMiniprogram panic recovered: %v", r)
}
}()
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
var userID string
authOK := false
// 读取首条消息auth
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var authMsg struct {
Type string `json:"type"`
UserID string `json:"userId"`
}
if json.Unmarshal(msg, &authMsg) == nil && authMsg.Type == "auth" && authMsg.UserID != "" {
userID = strings.TrimSpace(authMsg.UserID)
// 占位:校验用户存在即可
db := database.DB()
var u model.User
if db.Where("id = ?", userID).First(&u).Error == nil {
authOK = true
}
}
if !authOK {
conn.WriteJSON(map[string]interface{}{"type": "error", "message": "auth failed"})
return
}
// 鉴权通过,开始处理心跳
conn.SetReadDeadline(time.Time{}) // 取消超时
client := redis.Client()
if client == nil {
log.Printf("[WS] Redis 未启用,在线状态不可用")
return
}
key := wsOnlinePrefix + userID
ctx := context.Background()
ttl := time.Duration(wsOfflineTimeout) * time.Second
// 立即写入一次在线
client.Set(ctx, key, "1", ttl)
// 心跳读取循环
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
var m struct {
Type string `json:"type"`
}
if json.Unmarshal(msg, &m) == nil && (m.Type == "ping" || m.Type == "heartbeat") {
client.Set(ctx, key, "1", ttl)
conn.WriteJSON(map[string]interface{}{"type": "pong"})
}
}
}
// AdminUsersOnlineStats GET /api/admin/users/online-stats 管理端在线人数统计
// 容错Redis 不可用时返回 success + onlineCount: 0不影响管理端其他功能
func AdminUsersOnlineStats(c *gin.Context) {
client := redis.Client()
if client == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
ctx := context.Background()
iter := client.Scan(ctx, 0, wsOnlinePrefix+"*", 0).Iterator()
count := 0
for iter.Next(ctx) {
count++
}
if err := iter.Err(); err != nil {
log.Printf("[WS] AdminUsersOnlineStats Redis scan err: %v降级返回 0", err)
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": count})
}
// GetWsURL 返回小程序可用的 WSS 地址(基于 API_BASE_URL 派生)
func GetWsURL() string {
cfg := config.Get()
if cfg == nil {
return ""
}
base := strings.TrimSuffix(cfg.BaseURL, "/")
if base == "" {
return ""
}
if strings.HasPrefix(base, "https://") {
return "wss" + strings.TrimPrefix(base, "https") + "/ws/miniprogram"
}
if strings.HasPrefix(base, "http://") {
return strings.Replace(base, "http", "ws", 1) + "/ws/miniprogram"
}
return ""
}