直播后台:新增按 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

@@ -83,3 +83,8 @@ 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
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 })

View File

@@ -37,6 +37,20 @@ const QUALITY_MEDIA = {
}
}
// 码率上限kbps: 在实时性与并发之间取平衡
const QUALITY_VIDEO_MAX_KBPS = {
source: 1800,
high: 1400,
mid: 900,
low: 550
}
const BITRATE_PROFILE_MULTIPLIER = {
save: 0.78,
balanced: 1,
clarity: 1.2
}
function effectivePublishQualityKey() {
try {
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
@@ -45,6 +59,27 @@ function effectivePublishQualityKey() {
return 'source'
}
function targetVideoMaxBitrateBps(publishKey, bitrateProfile = 'balanced') {
const kbps = QUALITY_VIDEO_MAX_KBPS[publishKey] || QUALITY_VIDEO_MAX_KBPS.source
const m = BITRATE_PROFILE_MULTIPLIER[bitrateProfile] || BITRATE_PROFILE_MULTIPLIER.balanced
return Math.max(220, Math.round(kbps * m)) * 1000
}
async function applyVideoSenderPolicy(sender, publishKey, bitrateProfile) {
if (!sender) return
try {
const p = sender.getParameters ? sender.getParameters() : null
if (!p) return
if (!p.encodings || !p.encodings.length) p.encodings = [{}]
p.degradationPreference = 'maintain-framerate'
p.encodings[0].maxBitrate = targetVideoMaxBitrateBps(publishKey, bitrateProfile)
// 保留一定冗余,弱网抖动时更稳,避免一路拉满
p.encodings[0].maxFramerate =
publishKey === 'low' ? 20 : publishKey === 'mid' ? 24 : 30
await sender.setParameters(p)
} catch (_) {}
}
function liveWsURLPublish(token) {
const q = effectivePublishQualityKey()
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
@@ -132,6 +167,7 @@ export function startPublishing(opts = {}) {
token = '',
captureMode: initialMode = 'camera',
videoDeviceId: initialDeviceId = '',
bitrateProfile = 'balanced',
onStatus = () => {},
onLocalStream = () => {},
onActiveModeChange = () => {},
@@ -245,6 +281,9 @@ export function startPublishing(opts = {}) {
throw e
}
const previewVid = new MediaStream([sTr])
try {
sTr.contentHint = 'detail'
} catch (_) {}
onLocalStream({ layout: 'screen_only', main: previewVid })
return new MediaStream([sTr, ...micStream.getAudioTracks()])
}
@@ -309,6 +348,9 @@ export function startPublishing(opts = {}) {
cam.getTracks().forEach((t) => t.stop())
throw new Error('画布采集失败')
}
try {
outV.contentHint = 'detail'
} catch (_) {}
const mic = cam.getAudioTracks()
const publish = new MediaStream([outV, ...mic])
onLocalStream({
@@ -319,6 +361,10 @@ export function startPublishing(opts = {}) {
return publish
}
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
try {
const camV = s.getVideoTracks()[0]
if (camV) camV.contentHint = 'motion'
} catch (_) {}
onLocalStream({ layout: 'camera', main: s })
return s
}
@@ -359,6 +405,11 @@ export function startPublishing(opts = {}) {
stream.getTracks().forEach((t) => {
if (t.readyState === 'live') pc.addTrack(t, stream)
})
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
@@ -410,6 +461,11 @@ export function startPublishing(opts = {}) {
} else if (aT) {
pc.addTrack(aT, stream)
}
await applyVideoSenderPolicy(
pc.getSenders().find((s) => s.track?.kind === 'video'),
publishKey,
bitrateProfile
)
const offer = await pc.createOffer({ iceRestart: false })
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })

View File

@@ -37,6 +37,19 @@
</el-select>
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
</div>
<div class="field-row">
<span class="field-label">码率策略</span>
<el-select
v-model="bitrateProfile"
style="width: 100%; max-width: 300px"
:disabled="!token || switchingCapture"
>
<el-option label="省流优先(更多并发)" value="save" />
<el-option label="均衡(推荐)" value="balanced" />
<el-option label="清晰优先(更占带宽)" value="clarity" />
</el-select>
<el-tag effect="plain" type="info">弱网建议省流/均衡</el-tag>
</div>
<p v-if="session" class="hint-live">
直播中可切换三种模式屏幕+摄像头下可拖动小窗观众画面与预览一致画布 16:9
铺满共享整屏时尽量勿把本管理页选进画面以免套娃
@@ -87,6 +100,65 @@
></video>
</div>
</el-card>
<el-card class="moderation-card" style="margin-top: 16px">
<template #header>
<div class="moderation-head">
<span>发言管控 IP</span>
<el-button size="small" @click="loadModeration">刷新</el-button>
</div>
</template>
<div class="moderation-actions">
<el-switch
v-model="muteAll"
active-text="全体禁言"
inactive-text="允许发言"
@change="toggleMuteAll"
/>
<el-input v-model.trim="manualIP" placeholder="手动输入 IP如 1.2.3.4" style="width: 260px" />
<el-button type="warning" plain @click="setManualIPMute(true)">禁言该IP</el-button>
<el-button @click="setManualIPMute(false)">解禁该IP</el-button>
</div>
<p class="moderation-hint">
IP {{ moderationRate.window_ms }}ms 内最多 {{ moderationRate.max_hits }} 次发送超出会本地限频优先保障实时
</p>
<el-table v-loading="moderationLoading" :data="onlineIPs" stripe style="width: 100%">
<el-table-column prop="ip" label="IP" min-width="180" />
<el-table-column prop="count" label="在线连接数" width="100" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '已禁言' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="!row.muted" link type="warning" @click="toggleIP(row.ip, true)">禁言</el-button>
<el-button v-else link type="primary" @click="toggleIP(row.ip, false)">解禁</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="moderation-subtitle">在线用户会话</h4>
<el-table v-loading="moderationLoading" :data="onlineUsers" stripe style="width: 100%">
<el-table-column prop="channel" label="通道" width="96" />
<el-table-column prop="username" label="用户(脱敏)" min-width="120" />
<el-table-column prop="ip" label="IP" min-width="160" />
<el-table-column label="在线时长" width="110">
<template #default="{ row }">{{ formatSec(row.online_sec) }}</template>
</el-table-column>
<el-table-column label="空闲时长" width="110">
<template #default="{ row }">{{ formatSec(row.idle_sec) }}</template>
</el-table-column>
<el-table-column label="禁言" width="80">
<template #default="{ row }">
<el-tag :type="row.muted ? 'warning' : 'success'" size="small">
{{ row.muted ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -94,6 +166,8 @@
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 { startPublishing } from '../../utils/liveWebRTC'
function liveStatusUrl() {
@@ -113,8 +187,16 @@ const viewerCount = ref(0)
let viewerPollTimer = null
const captureMode = ref('camera')
const selectedCameraId = ref('')
const bitrateProfile = ref('balanced')
const videoInputs = ref([])
const switchingCapture = ref(false)
const moderationLoading = ref(false)
const muteAll = ref(false)
const onlineIPs = ref([])
const onlineUsers = ref([])
const manualIP = ref('')
const moderationRate = ref({ window_ms: 3000, max_hits: 10 })
let moderationTimer = null
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
const previewLayout = ref('camera')
@@ -188,6 +270,61 @@ async function refreshVideoDevices() {
} catch (_) {}
}
async function loadModeration() {
moderationLoading.value = true
try {
const res = await getLiveModeration()
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 : []
moderationRate.value = res.rate_limit || { window_ms: 3000, max_hits: 10 }
} catch (e) {
ElMessage.error(e?.response?.data?.error || '加载发言管控失败')
} finally {
moderationLoading.value = false
}
}
function formatSec(s) {
const v = Math.max(0, Number(s) || 0)
if (v < 60) return `${v}s`
const m = Math.floor(v / 60)
const r = v % 60
if (m < 60) return `${m}m${r}s`
const h = Math.floor(m / 60)
const mm = m % 60
return `${h}h${mm}m`
}
async function toggleMuteAll(v) {
try {
await setLiveMuteAll(!!v)
ElMessage.success(v ? '已开启全体禁言' : '已关闭全体禁言')
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
muteAll.value = !v
}
}
async function toggleIP(ip, enabled) {
try {
await setLiveMuteIP(ip, enabled)
ElMessage.success(enabled ? `已禁言 ${ip}` : `已解禁 ${ip}`)
await loadModeration()
} catch (e) {
ElMessage.error(e?.response?.data?.error || '操作失败')
}
}
async function setManualIPMute(enabled) {
if (!manualIP.value) {
ElMessage.warning('请先输入 IP')
return
}
await toggleIP(manualIP.value, enabled)
}
function applyPreview(payload) {
const { layout, main, screen, cam } = payload || {}
previewLayout.value = layout || 'camera'
@@ -261,6 +398,7 @@ function start() {
token: token.value,
captureMode: captureMode.value,
videoDeviceId: selectedCameraId.value || '',
bitrateProfile: bitrateProfile.value,
onStatus: (s) => {
status.value = s
},
@@ -287,11 +425,27 @@ function onBeforeUnload() {
onMounted(() => {
document.title = '视频直播开播 - 管理后台'
window.addEventListener('beforeunload', onBeforeUnload)
try {
const v = localStorage.getItem('yh_live_bitrate_profile')
bitrateProfile.value = v === 'save' || v === 'clarity' ? v : 'balanced'
} catch (_) {}
refreshVideoDevices()
loadModeration()
moderationTimer = window.setInterval(loadModeration, 5000)
})
watch(bitrateProfile, (v) => {
try {
localStorage.setItem('yh_live_bitrate_profile', v)
} catch (_) {}
})
onUnmounted(() => {
stopViewerPoll()
if (moderationTimer != null) {
clearInterval(moderationTimer)
moderationTimer = null
}
window.removeEventListener('beforeunload', onBeforeUnload)
window.removeEventListener('pointermove', onPipPointerMove)
})
@@ -371,4 +525,29 @@ onBeforeRouteLeave(() => {
.preview-pip-drag:active {
cursor: grabbing;
}
.moderation-card {
max-width: min(1200px, 100%);
}
.moderation-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.moderation-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.moderation-hint {
margin: 0 0 10px;
color: #909399;
font-size: 12px;
}
.moderation-subtitle {
margin: 16px 0 10px;
font-size: 14px;
color: #303133;
}
</style>

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