Files
jiang13-bbs/backend/handler/realtime.go
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

127 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/realtime"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// parseWSChatRoom 解析 WS 房间名 chat:{roomID}
func parseWSChatRoom(room string) (uint, bool) {
idStr := strings.TrimPrefix(room, "chat:")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
return 0, false
}
return uint(id), true
}
// 实时总线 WebSocket 入口:GET /api/ws
//
// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
// 防止跨站页面在用户浏览器中发起握手。
func (h *Handlers) RealtimeWS(c *gin.Context) {
tokenStr, err := c.Cookie(service.CookieName)
if err != nil || tokenStr == "" {
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
tokenStr = strings.TrimPrefix(auth, "Bearer ")
}
}
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
claims, err := h.Auth.ParseToken(tokenStr)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态无效"})
return
}
if _, err := h.Auth.ValidateClaims(claims); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态已失效"})
return
}
upgrader := websocket.Upgrader{
HandshakeTimeout: 5 * time.Second,
CheckOrigin: h.checkWSOrigin,
// 子协议不传:鉴权完全走 cookie,禁止 Sec-WebSocket-Protocol 携带 token
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return // Upgrade 内部已写错误响应
}
// 建连时取实时角色快照决定能否进入 staff 房间(角色变更后旧 JWT 会失效,
// 用户重连即按新角色重新入房)
isStaff := false
canOverseeChat := false
if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil {
isStaff = actor.IsStaff()
canOverseeChat = service.CanOverseeChat(actor.Role)
}
client := realtime.NewClient(
h.Hub,
conn,
claims.ID,
isStaff,
func() bool {
_, verr := h.Auth.ValidateClaims(claims)
return verr == nil
},
func() { h.Auth.TouchLastSeen(claims.ID) },
// 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群
func(room string) bool {
roomID, ok := parseWSChatRoom(room)
if !ok {
return false
}
if canOverseeChat {
return true
}
_, err := h.Chat.Membership(roomID, claims.ID)
return err == nil
},
)
client.Serve(func(cl *realtime.Client) {
// hello 帧:全员带 user_id/ts;staff 额外带在线用户快照用于校正绿点
data := gin.H{
"user_id": claims.ID,
"ts": time.Now().Unix(),
}
if isStaff {
data["online_user_ids"] = h.Hub.OnlineUserIDs()
}
cl.Send(realtime.Envelope{Type: realtime.EventHello, Data: data})
})
}
// checkWSOrigin 校验握手 Origin:
// - 无 Origin(非浏览器/native 客户端)放行
// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
// - 生产:Origin 主机必须与请求 Host 同源
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Scheme != "http" && u.Scheme != "https" {
return false
}
if h.Cfg.DevMode {
host := u.Hostname()
return host == "localhost" || host == "127.0.0.1"
}
return u.Host == r.Host
}