feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

126
backend/handler/realtime.go Normal file
View 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
}