feat(live): 按用户名禁言、后台右侧管控栏、前台抖音式布局与禁言 Toast

Made-with: Cursor
This commit is contained in:
whm
2026-03-27 09:11:39 +08:00
parent fe8d5a34cc
commit 435fbfd47e
10 changed files with 419 additions and 221 deletions

View File

@@ -62,8 +62,10 @@ func handleDanmakuWS(c *gin.Context) {
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
canSend := tokenOK
fromDisplay := "***"
fullUsername := ""
if tokenOK && claims != nil {
fromDisplay = handlers.MaskSiteUsernameForDanmaku(claims.Username)
fullUsername = strings.TrimSpace(claims.Username)
}
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
@@ -72,7 +74,7 @@ func handleDanmakuWS(c *gin.Context) {
}
ws.SetReadLimit(4096)
clientIP := c.ClientIP()
sessionID := RegisterOnlineSession("danmaku", clientIP, fromDisplay)
sessionID := RegisterOnlineSession("danmaku", clientIP, fullUsername)
danmakuClientsMu.Lock()
danmakuClients[ws] = clientIP
@@ -95,11 +97,11 @@ func handleDanmakuWS(c *gin.Context) {
continue
}
TouchOnlineSession(sessionID)
if IsMutedForIP(clientIP) {
if IsMutedForSend(clientIP, fullUsername) {
_ = writeDanmakuJSON(ws, map[string]interface{}{
"type": "error",
"code": "muted",
"message": "当前已被禁言",
"message": "已被禁言,暂时无法发弹幕或送礼物",
})
continue
}

View File

@@ -3,6 +3,7 @@ package weblive
import (
"fmt"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -10,18 +11,20 @@ import (
var (
modMu sync.RWMutex
muteAll bool
mutedIP = make(map[string]bool)
ipWindow = make(map[string][]int64) // 每个 IP 最近窗口内的发送时间戳(ms)
muteAll bool
mutedIP = make(map[string]bool)
mutedUsers = make(map[string]bool) // key: normMuteUsername
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"`
MuteAll bool `json:"mute_all"`
MutedIPs []string `json:"muted_ips"`
MutedUsernames []string `json:"muted_usernames"`
OnlineIPs []IPOnlineItem `json:"online_ips"`
OnlineUsers []OnlineUserItem `json:"online_users"`
RateLimit struct {
WindowMs int `json:"window_ms"`
MaxHits int `json:"max_hits"`
@@ -73,6 +76,25 @@ func SetIPMuted(ip string, enabled bool) {
modMu.Unlock()
}
func normMuteUsername(u string) string {
return strings.ToLower(strings.TrimSpace(u))
}
// SetUserMuted 按登录用户名禁言(弹幕/礼物);与 IP 禁言、全体禁言叠加。
func SetUserMuted(username string, enabled bool) {
key := normMuteUsername(username)
if key == "" {
return
}
modMu.Lock()
if enabled {
mutedUsers[key] = true
} else {
delete(mutedUsers, key)
}
modMu.Unlock()
}
const (
ipSendWindowMs = 3000
ipSendMaxHits = 10
@@ -93,6 +115,22 @@ func IsMutedForIP(ip string) bool {
return mutedIP[ip]
}
// IsMutedForSend 发弹幕/礼物前全体禁言、IP 禁言、或已登录用户名被禁。
func IsMutedForSend(ip, username string) bool {
modMu.RLock()
defer modMu.RUnlock()
if muteAll {
return true
}
if mutedIP[ip] {
return true
}
if k := normMuteUsername(username); k != "" && mutedUsers[k] {
return true
}
return false
}
func ModerationStateSnapshot() ModerationSnapshot {
modMu.RLock()
muteAllNow := muteAll
@@ -100,8 +138,13 @@ func ModerationStateSnapshot() ModerationSnapshot {
for ip := range mutedIP {
muted = append(muted, ip)
}
mutedNames := make([]string, 0, len(mutedUsers))
for u := range mutedUsers {
mutedNames = append(mutedNames, u)
}
modMu.RUnlock()
sort.Strings(muted)
sort.Strings(mutedNames)
counts := onlineIPCountsLocked()
online := make([]IPOnlineItem, 0, len(counts))
@@ -215,7 +258,7 @@ func onlineUsersLocked() []OnlineUserItem {
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,
Muted: IsMutedForSend(s.IP, s.Username),
})
}
sort.Slice(out, func(i, j int) bool {

View File

@@ -40,3 +40,21 @@ func PutLiveMuteIP(c *gin.Context) {
SetIPMuted(ip, body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func PutLiveMuteUser(c *gin.Context) {
var body struct {
Username string `json:"username"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
return
}
u := strings.TrimSpace(body.Username)
if u == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名不能为空"})
return
}
SetUserMuted(u, body.Enabled)
c.JSON(http.StatusOK, gin.H{"ok": true})
}

View File

@@ -218,7 +218,7 @@ func handleViewerWS(c *gin.Context, h *Hub) {
}
vid := uuid.New().String()
vs := &viewerSession{id: vid, ws: ws}
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "***")
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "")
h.mu.Lock()
h.viewers[vid] = vs