feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
126
backend/handler/realtime.go
Normal file
126
backend/handler/realtime.go
Normal file
@@ -0,0 +1,126 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user