150 lines
3.9 KiB
Go
150 lines
3.9 KiB
Go
|
|
// 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 ""
|
|||
|
|
}
|