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