直播后台:新增按 IP 发言管控与在线会话视图。

支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 17:57:50 +08:00
parent 0da93fb1be
commit fe8d5a34cc
8 changed files with 535 additions and 2 deletions

View File

@@ -145,6 +145,9 @@ func main() {
c.JSON(http.StatusOK, structure)
})
admin.GET("/stats", handlers.GetStats)
admin.GET("/live/moderation", handlers.RequirePermission(models.PermHomepageEdit), weblive.GetLiveModeration)
admin.PUT("/live/moderation/mute-all", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteAll)
admin.PUT("/live/moderation/mute-ip", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteIP)
// 用户管理
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)

View File

@@ -34,7 +34,7 @@ var allowedGifts = map[string]struct{}{
var (
danmakuClientsMu sync.Mutex
danmakuClients = make(map[*websocket.Conn]struct{})
danmakuClients = make(map[*websocket.Conn]string)
)
func writeDanmakuJSON(ws *websocket.Conn, v any) error {
@@ -71,12 +71,15 @@ func handleDanmakuWS(c *gin.Context) {
return
}
ws.SetReadLimit(4096)
clientIP := c.ClientIP()
sessionID := RegisterOnlineSession("danmaku", clientIP, fromDisplay)
danmakuClientsMu.Lock()
danmakuClients[ws] = struct{}{}
danmakuClients[ws] = clientIP
danmakuClientsMu.Unlock()
defer func() {
UnregisterOnlineSession(sessionID)
danmakuClientsMu.Lock()
delete(danmakuClients, ws)
danmakuClientsMu.Unlock()
@@ -91,6 +94,23 @@ func handleDanmakuWS(c *gin.Context) {
if mt != websocket.TextMessage {
continue
}
TouchOnlineSession(sessionID)
if IsMutedForIP(clientIP) {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "muted",
"message": "当前已被禁言",
})
continue
}
if !AllowSendByIP(clientIP) {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "rate_limited",
"message": "同 IP 发送过快,请稍后再试",
})
continue
}
if !canSend {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",

View File

@@ -0,0 +1,225 @@
package weblive
import (
"fmt"
"sort"
"sync"
"sync/atomic"
"time"
)
var (
modMu sync.RWMutex
muteAll bool
mutedIP = make(map[string]bool)
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
onlineMap = make(map[string]*onlineSession)
seq uint64
)
type ModerationSnapshot struct {
MuteAll bool `json:"mute_all"`
MutedIPs []string `json:"muted_ips"`
OnlineIPs []IPOnlineItem `json:"online_ips"`
OnlineUsers []OnlineUserItem `json:"online_users"`
RateLimit struct {
WindowMs int `json:"window_ms"`
MaxHits int `json:"max_hits"`
} `json:"rate_limit"`
}
type IPOnlineItem struct {
IP string `json:"ip"`
Count int `json:"count"`
Muted bool `json:"muted"`
}
type onlineSession struct {
ID string
IP string
Username string
Channel string
Connected time.Time
LastAt time.Time
}
type OnlineUserItem struct {
ID string `json:"id"`
IP string `json:"ip"`
Username string `json:"username"`
Channel string `json:"channel"`
ConnectedAt string `json:"connected_at"`
OnlineSec int64 `json:"online_sec"`
IdleSec int64 `json:"idle_sec"`
Muted bool `json:"muted"`
}
func SetMuteAll(enabled bool) {
modMu.Lock()
muteAll = enabled
modMu.Unlock()
}
func SetIPMuted(ip string, enabled bool) {
if ip == "" {
return
}
modMu.Lock()
if enabled {
mutedIP[ip] = true
} else {
delete(mutedIP, ip)
}
modMu.Unlock()
}
const (
ipSendWindowMs = 3000
ipSendMaxHits = 10
)
func IsIPMuted(ip string) bool {
modMu.RLock()
defer modMu.RUnlock()
return mutedIP[ip]
}
func IsMutedForIP(ip string) bool {
modMu.RLock()
defer modMu.RUnlock()
if muteAll {
return true
}
return mutedIP[ip]
}
func ModerationStateSnapshot() ModerationSnapshot {
modMu.RLock()
muteAllNow := muteAll
muted := make([]string, 0, len(mutedIP))
for ip := range mutedIP {
muted = append(muted, ip)
}
modMu.RUnlock()
sort.Strings(muted)
counts := onlineIPCountsLocked()
online := make([]IPOnlineItem, 0, len(counts))
for ip, n := range counts {
online = append(online, IPOnlineItem{IP: ip, Count: n, Muted: IsIPMuted(ip)})
}
sort.Slice(online, func(i, j int) bool {
if online[i].Count == online[j].Count {
return online[i].IP < online[j].IP
}
return online[i].Count > online[j].Count
})
users := onlineUsersLocked()
out := ModerationSnapshot{MuteAll: muteAllNow, MutedIPs: muted, OnlineIPs: online, OnlineUsers: users}
out.RateLimit.WindowMs = ipSendWindowMs
out.RateLimit.MaxHits = ipSendMaxHits
return out
}
// AllowSendByIP 本地内存限频(同 IP 先本地判定,避免刷爆)
func AllowSendByIP(ip string) bool {
if ip == "" {
return true
}
now := time.Now().UnixMilli()
cut := now - ipSendWindowMs
modMu.Lock()
defer modMu.Unlock()
arr := ipWindow[ip]
if len(arr) > 0 {
k := 0
for _, ts := range arr {
if ts >= cut {
arr[k] = ts
k++
}
}
arr = arr[:k]
}
if len(arr) >= ipSendMaxHits {
ipWindow[ip] = arr
return false
}
arr = append(arr, now)
if len(arr) > ipSendMaxHits*4 {
arr = arr[len(arr)-ipSendMaxHits*2:]
}
ipWindow[ip] = arr
return true
}
func RegisterOnlineSession(channel, ip, username string) string {
now := time.Now()
id := fmt.Sprintf("%s-%d", channel, atomic.AddUint64(&seq, 1))
modMu.Lock()
onlineMap[id] = &onlineSession{
ID: id,
IP: ip,
Username: username,
Channel: channel,
Connected: now,
LastAt: now,
}
modMu.Unlock()
return id
}
func TouchOnlineSession(id string) {
if id == "" {
return
}
modMu.Lock()
if s := onlineMap[id]; s != nil {
s.LastAt = time.Now()
}
modMu.Unlock()
}
func UnregisterOnlineSession(id string) {
if id == "" {
return
}
modMu.Lock()
delete(onlineMap, id)
modMu.Unlock()
}
func onlineIPCountsLocked() map[string]int {
out := make(map[string]int)
for _, s := range onlineMap {
if s == nil || s.IP == "" {
continue
}
out[s.IP]++
}
return out
}
func onlineUsersLocked() []OnlineUserItem {
now := time.Now()
out := make([]OnlineUserItem, 0, len(onlineMap))
for _, s := range onlineMap {
if s == nil {
continue
}
out = append(out, OnlineUserItem{
ID: s.ID,
IP: s.IP,
Username: s.Username,
Channel: s.Channel,
ConnectedAt: s.Connected.Format(time.RFC3339),
OnlineSec: int64(now.Sub(s.Connected).Seconds()),
IdleSec: int64(now.Sub(s.LastAt).Seconds()),
Muted: mutedIP[s.IP] || muteAll,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].OnlineSec > out[j].OnlineSec
})
return out
}

View File

@@ -0,0 +1,42 @@
package weblive
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func GetLiveModeration(c *gin.Context) {
c.JSON(http.StatusOK, ModerationStateSnapshot())
}
func PutLiveMuteAll(c *gin.Context) {
var body struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
SetMuteAll(body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PutLiveMuteIP(c *gin.Context) {
var body struct {
IP string `json:"ip"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
ip := strings.TrimSpace(body.IP)
if ip == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "IP 不能为空"})
return
}
SetIPMuted(ip, body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -218,12 +218,14 @@ func handleViewerWS(c *gin.Context, h *Hub) {
}
vid := uuid.New().String()
vs := &viewerSession{id: vid, ws: ws}
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "***")
h.mu.Lock()
h.viewers[vid] = vs
h.mu.Unlock()
defer func() {
UnregisterOnlineSession(sessionID)
h.removeViewer(vid)
_ = ws.Close()
}()
@@ -255,6 +257,7 @@ func handleViewerWS(c *gin.Context, h *Hub) {
if err != nil {
return
}
TouchOnlineSession(sessionID)
var env wsEnvelope
if err := json.Unmarshal(data, &env); err != nil {
continue