直播后台:新增按 IP 发言管控与在线会话视图。
支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。 Made-with: Cursor
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
225
server/pkg/weblive/moderation.go
Normal file
225
server/pkg/weblive/moderation.go
Normal 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
|
||||
}
|
||||
42
server/pkg/weblive/moderation_api.go
Normal file
42
server/pkg/weblive/moderation_api.go
Normal 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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user