From 435fbfd47ed48de69df6e81522dbe9d7f36a133a Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Fri, 27 Mar 2026 09:11:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(live):=20=E6=8C=89=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=90=8D=E7=A6=81=E8=A8=80=E3=80=81=E5=90=8E=E5=8F=B0=E5=8F=B3?= =?UTF-8?q?=E4=BE=A7=E7=AE=A1=E6=8E=A7=E6=A0=8F=E3=80=81=E5=89=8D=E5=8F=B0?= =?UTF-8?q?=E6=8A=96=E9=9F=B3=E5=BC=8F=E5=B8=83=E5=B1=80=E4=B8=8E=E7=A6=81?= =?UTF-8?q?=E8=A8=80=20Toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .gitignore | 3 - admin/src/api/admin.js | 4 +- admin/src/views/sites/LiveBroadcast.vue | 199 +++++++++----- scripts/import-promotion-to-api.sh | 3 +- server/main.go | 1 + server/pkg/weblive/danmaku.go | 8 +- server/pkg/weblive/moderation.go | 59 +++- server/pkg/weblive/moderation_api.go | 18 ++ server/pkg/weblive/ws.go | 2 +- web/src/views/LiveRoom.vue | 343 ++++++++++++++---------- 10 files changed, 419 insertions(+), 221 deletions(-) diff --git a/.gitignore b/.gitignore index c3af456..7d3e6bc 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,3 @@ web/promotion/视频发布/**/*.webp # PPT 解压临时目录与压缩包副本(仅保留 .pptx 源文件即可) web/promotion/_pptx_extract/ web/promotion/_pptx.zip - -# yh_nginx 启动脚本生成的 conf(宿主机挂载目录,勿提交) -nginx/runtime-confd/default.conf diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js index a99a1ea..cdbabf2 100644 --- a/admin/src/api/admin.js +++ b/admin/src/api/admin.js @@ -84,7 +84,9 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => { export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path }) export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`) -// 直播发言管控(按 IP) +// 直播发言管控(全体 / IP / 用户名) export const getLiveModeration = () => request.get('/admin/live/moderation') export const setLiveMuteAll = (enabled) => request.put('/admin/live/moderation/mute-all', { enabled }) export const setLiveMuteIP = (ip, enabled) => request.put('/admin/live/moderation/mute-ip', { ip, enabled }) +export const setLiveMuteUser = (username, enabled) => + request.put('/admin/live/moderation/mute-user', { username, enabled }) diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue index 718654b..bceb608 100644 --- a/admin/src/views/sites/LiveBroadcast.vue +++ b/admin/src/views/sites/LiveBroadcast.vue @@ -1,6 +1,7 @@ @@ -167,7 +198,7 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue' import { onBeforeRouteLeave } from 'vue-router' import { useAuthStore } from '../../stores/auth' import { ElMessage } from 'element-plus' -import { getLiveModeration, setLiveMuteAll, setLiveMuteIP } from '../../api/admin' +import { getLiveModeration, setLiveMuteAll, setLiveMuteIP, setLiveMuteUser } from '../../api/admin' import { startPublishing } from '../../utils/liveWebRTC' function liveStatusUrl() { @@ -195,6 +226,8 @@ const muteAll = ref(false) const onlineIPs = ref([]) const onlineUsers = ref([]) const manualIP = ref('') +const manualUsername = ref('') +const mutedUsernames = ref([]) const moderationRate = ref({ window_ms: 3000, max_hits: 10 }) let moderationTimer = null /** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */ @@ -277,6 +310,7 @@ async function loadModeration() { muteAll.value = !!res.mute_all onlineIPs.value = Array.isArray(res.online_ips) ? res.online_ips : [] onlineUsers.value = Array.isArray(res.online_users) ? res.online_users : [] + mutedUsernames.value = Array.isArray(res.muted_usernames) ? res.muted_usernames : [] moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 } } catch (e) { ElMessage.error(e?.response?.data?.error || '加载发言管控失败') @@ -325,6 +359,26 @@ async function setManualIPMute(enabled) { await toggleIP(manualIP.value, enabled) } +async function toggleUserMute(username, enabled) { + const u = (username || '').trim() + if (!u) return + try { + await setLiveMuteUser(u, enabled) + ElMessage.success(enabled ? `已禁言用户 ${u}` : `已解禁用户 ${u}`) + await loadModeration() + } catch (e) { + ElMessage.error(e?.response?.data?.error || '操作失败') + } +} + +async function setManualUserMute(enabled) { + if (!manualUsername.value.trim()) { + ElMessage.warning('请先输入用户名') + return + } + await toggleUserMute(manualUsername.value.trim(), enabled) +} + function applyPreview(payload) { const { layout, main, screen, cam } = payload || {} previewLayout.value = layout || 'camera' @@ -526,7 +580,30 @@ onBeforeRouteLeave(() => { cursor: grabbing; } .moderation-card { - max-width: min(1200px, 100%); + max-width: 100%; +} +.moderation-actions--stack { + flex-direction: column; + align-items: stretch; +} +.moderation-inline { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.muted-names-line { + margin: 0 0 8px; + font-size: 12px; + color: #606266; + line-height: 1.6; +} +.muted-name-tag { + margin: 2px 4px 2px 0; +} +.moderation-table { + width: 100%; + margin-bottom: 8px; } .moderation-head { display: flex; diff --git a/scripts/import-promotion-to-api.sh b/scripts/import-promotion-to-api.sh index 798ce5a..777c73e 100644 --- a/scripts/import-promotion-to-api.sh +++ b/scripts/import-promotion-to-api.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash # 将 web/promotion/视频发布 导入到 data/uploads + MongoDB site_assets(无需后台手动上传) -# 部署时若已在 server/.env 设置 YH_IMPORT_PROMOTION_SITE_ID,pull-and-restart/restart 会在 compose up 后自动执行等效逻辑(Docker 内 go run,见 run-promotion-import-on-deploy.sh)。 -# 本脚本:在宿主机直接 go run,依赖 server/.env 中 MONGODB_URI 能连上 Mongo(若 Mongo 仅在 compose 内网,请用自动导入或 docker 网络) +# 依赖:server/.env 中 MONGODB_URI、MONGODB_DB(与 API 一致);本机可连 Mongo # # 用法: # ./scripts/import-promotion-to-api.sh -site=你的站点MongoID diff --git a/server/main.go b/server/main.go index 23676c6..00dfa47 100644 --- a/server/main.go +++ b/server/main.go @@ -148,6 +148,7 @@ func main() { 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.PUT("/live/moderation/mute-user", handlers.RequirePermission(models.PermHomepageEdit), weblive.PutLiveMuteUser) // 用户管理 admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers) diff --git a/server/pkg/weblive/danmaku.go b/server/pkg/weblive/danmaku.go index 7d9b0d1..f222c5b 100644 --- a/server/pkg/weblive/danmaku.go +++ b/server/pkg/weblive/danmaku.go @@ -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 } diff --git a/server/pkg/weblive/moderation.go b/server/pkg/weblive/moderation.go index 937545b..7b6282e 100644 --- a/server/pkg/weblive/moderation.go +++ b/server/pkg/weblive/moderation.go @@ -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 { diff --git a/server/pkg/weblive/moderation_api.go b/server/pkg/weblive/moderation_api.go index 661cf3b..e7cf28f 100644 --- a/server/pkg/weblive/moderation_api.go +++ b/server/pkg/weblive/moderation_api.go @@ -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}) +} diff --git a/server/pkg/weblive/ws.go b/server/pkg/weblive/ws.go index 9dbbbfe..c3f8bee 100644 --- a/server/pkg/weblive/ws.go +++ b/server/pkg/weblive/ws.go @@ -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 diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index 15d56ce..de5d299 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -20,130 +20,119 @@

{{ watchStatus }}

-
-
- -