直播后台:新增按 IP 发言管控与在线会话视图。
支持全体禁言、单 IP 禁言/解禁与同 IP 本地限频,同时在开播页展示在线会话明细并补充实时优先码率策略,兼顾实时性与并发承载。 Made-with: Cursor
This commit is contained in:
@@ -83,3 +83,8 @@ export const uploadSiteAsset = (siteId, file, opts = {}) => {
|
|||||||
}
|
}
|
||||||
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
export const createSiteFolder = (siteId, path) => request.post(`/admin/sites/${siteId}/folders`, { path })
|
||||||
export const deleteSiteAsset = (siteId, id) => request.delete(`/admin/sites/${siteId}/assets/${id}`)
|
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 })
|
||||||
|
|||||||
@@ -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() {
|
function effectivePublishQualityKey() {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
|
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
|
||||||
@@ -45,6 +59,27 @@ function effectivePublishQualityKey() {
|
|||||||
return 'source'
|
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) {
|
function liveWsURLPublish(token) {
|
||||||
const q = effectivePublishQualityKey()
|
const q = effectivePublishQualityKey()
|
||||||
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
|
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
|
||||||
@@ -132,6 +167,7 @@ export function startPublishing(opts = {}) {
|
|||||||
token = '',
|
token = '',
|
||||||
captureMode: initialMode = 'camera',
|
captureMode: initialMode = 'camera',
|
||||||
videoDeviceId: initialDeviceId = '',
|
videoDeviceId: initialDeviceId = '',
|
||||||
|
bitrateProfile = 'balanced',
|
||||||
onStatus = () => {},
|
onStatus = () => {},
|
||||||
onLocalStream = () => {},
|
onLocalStream = () => {},
|
||||||
onActiveModeChange = () => {},
|
onActiveModeChange = () => {},
|
||||||
@@ -245,6 +281,9 @@ export function startPublishing(opts = {}) {
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
const previewVid = new MediaStream([sTr])
|
const previewVid = new MediaStream([sTr])
|
||||||
|
try {
|
||||||
|
sTr.contentHint = 'detail'
|
||||||
|
} catch (_) {}
|
||||||
onLocalStream({ layout: 'screen_only', main: previewVid })
|
onLocalStream({ layout: 'screen_only', main: previewVid })
|
||||||
return new MediaStream([sTr, ...micStream.getAudioTracks()])
|
return new MediaStream([sTr, ...micStream.getAudioTracks()])
|
||||||
}
|
}
|
||||||
@@ -309,6 +348,9 @@ export function startPublishing(opts = {}) {
|
|||||||
cam.getTracks().forEach((t) => t.stop())
|
cam.getTracks().forEach((t) => t.stop())
|
||||||
throw new Error('画布采集失败')
|
throw new Error('画布采集失败')
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
outV.contentHint = 'detail'
|
||||||
|
} catch (_) {}
|
||||||
const mic = cam.getAudioTracks()
|
const mic = cam.getAudioTracks()
|
||||||
const publish = new MediaStream([outV, ...mic])
|
const publish = new MediaStream([outV, ...mic])
|
||||||
onLocalStream({
|
onLocalStream({
|
||||||
@@ -319,6 +361,10 @@ export function startPublishing(opts = {}) {
|
|||||||
return publish
|
return publish
|
||||||
}
|
}
|
||||||
const s = await navigator.mediaDevices.getUserMedia(buildCameraConstraints(publishKey, deviceIdState))
|
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 })
|
onLocalStream({ layout: 'camera', main: s })
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -359,6 +405,11 @@ export function startPublishing(opts = {}) {
|
|||||||
stream.getTracks().forEach((t) => {
|
stream.getTracks().forEach((t) => {
|
||||||
if (t.readyState === 'live') pc.addTrack(t, stream)
|
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()
|
const offer = await pc.createOffer()
|
||||||
await pc.setLocalDescription(offer)
|
await pc.setLocalDescription(offer)
|
||||||
send({ type: 'offer', sdp: offer.sdp })
|
send({ type: 'offer', sdp: offer.sdp })
|
||||||
@@ -410,6 +461,11 @@ export function startPublishing(opts = {}) {
|
|||||||
} else if (aT) {
|
} else if (aT) {
|
||||||
pc.addTrack(aT, stream)
|
pc.addTrack(aT, stream)
|
||||||
}
|
}
|
||||||
|
await applyVideoSenderPolicy(
|
||||||
|
pc.getSenders().find((s) => s.track?.kind === 'video'),
|
||||||
|
publishKey,
|
||||||
|
bitrateProfile
|
||||||
|
)
|
||||||
const offer = await pc.createOffer({ iceRestart: false })
|
const offer = await pc.createOffer({ iceRestart: false })
|
||||||
await pc.setLocalDescription(offer)
|
await pc.setLocalDescription(offer)
|
||||||
send({ type: 'offer', sdp: offer.sdp })
|
send({ type: 'offer', sdp: offer.sdp })
|
||||||
|
|||||||
@@ -37,6 +37,19 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
|
<el-button :disabled="!token" @click="refreshVideoDevices">刷新列表</el-button>
|
||||||
</div>
|
</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">
|
<p v-if="session" class="hint-live">
|
||||||
直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9
|
直播中可切换三种模式;「屏幕+摄像头」下可拖动小窗,观众画面与预览一致(画布 16:9
|
||||||
铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。
|
铺满)。共享整屏时尽量勿把本管理页选进画面,以免套娃。
|
||||||
@@ -87,6 +100,65 @@
|
|||||||
></video>
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,6 +166,8 @@
|
|||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getLiveModeration, setLiveMuteAll, setLiveMuteIP } from '../../api/admin'
|
||||||
import { startPublishing } from '../../utils/liveWebRTC'
|
import { startPublishing } from '../../utils/liveWebRTC'
|
||||||
|
|
||||||
function liveStatusUrl() {
|
function liveStatusUrl() {
|
||||||
@@ -113,8 +187,16 @@ const viewerCount = ref(0)
|
|||||||
let viewerPollTimer = null
|
let viewerPollTimer = null
|
||||||
const captureMode = ref('camera')
|
const captureMode = ref('camera')
|
||||||
const selectedCameraId = ref('')
|
const selectedCameraId = ref('')
|
||||||
|
const bitrateProfile = ref('balanced')
|
||||||
const videoInputs = ref([])
|
const videoInputs = ref([])
|
||||||
const switchingCapture = ref(false)
|
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'>} */
|
/** @type {import('vue').Ref<'camera'|'screen_only'|'screen_pip'>} */
|
||||||
const previewLayout = ref('camera')
|
const previewLayout = ref('camera')
|
||||||
|
|
||||||
@@ -188,6 +270,61 @@ async function refreshVideoDevices() {
|
|||||||
} catch (_) {}
|
} 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) {
|
function applyPreview(payload) {
|
||||||
const { layout, main, screen, cam } = payload || {}
|
const { layout, main, screen, cam } = payload || {}
|
||||||
previewLayout.value = layout || 'camera'
|
previewLayout.value = layout || 'camera'
|
||||||
@@ -261,6 +398,7 @@ function start() {
|
|||||||
token: token.value,
|
token: token.value,
|
||||||
captureMode: captureMode.value,
|
captureMode: captureMode.value,
|
||||||
videoDeviceId: selectedCameraId.value || '',
|
videoDeviceId: selectedCameraId.value || '',
|
||||||
|
bitrateProfile: bitrateProfile.value,
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
status.value = s
|
status.value = s
|
||||||
},
|
},
|
||||||
@@ -287,11 +425,27 @@ function onBeforeUnload() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.title = '视频直播开播 - 管理后台'
|
document.title = '视频直播开播 - 管理后台'
|
||||||
window.addEventListener('beforeunload', onBeforeUnload)
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('yh_live_bitrate_profile')
|
||||||
|
bitrateProfile.value = v === 'save' || v === 'clarity' ? v : 'balanced'
|
||||||
|
} catch (_) {}
|
||||||
refreshVideoDevices()
|
refreshVideoDevices()
|
||||||
|
loadModeration()
|
||||||
|
moderationTimer = window.setInterval(loadModeration, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(bitrateProfile, (v) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('yh_live_bitrate_profile', v)
|
||||||
|
} catch (_) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopViewerPoll()
|
stopViewerPoll()
|
||||||
|
if (moderationTimer != null) {
|
||||||
|
clearInterval(moderationTimer)
|
||||||
|
moderationTimer = null
|
||||||
|
}
|
||||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
window.removeEventListener('pointermove', onPipPointerMove)
|
window.removeEventListener('pointermove', onPipPointerMove)
|
||||||
})
|
})
|
||||||
@@ -371,4 +525,29 @@ onBeforeRouteLeave(() => {
|
|||||||
.preview-pip-drag:active {
|
.preview-pip-drag:active {
|
||||||
cursor: grabbing;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ func main() {
|
|||||||
c.JSON(http.StatusOK, structure)
|
c.JSON(http.StatusOK, structure)
|
||||||
})
|
})
|
||||||
admin.GET("/stats", handlers.GetStats)
|
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)
|
admin.GET("/users", handlers.RequirePermission(models.PermUserManage), handlers.GetUsers)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ var allowedGifts = map[string]struct{}{
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
danmakuClientsMu sync.Mutex
|
danmakuClientsMu sync.Mutex
|
||||||
danmakuClients = make(map[*websocket.Conn]struct{})
|
danmakuClients = make(map[*websocket.Conn]string)
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeDanmakuJSON(ws *websocket.Conn, v any) error {
|
func writeDanmakuJSON(ws *websocket.Conn, v any) error {
|
||||||
@@ -71,12 +71,15 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ws.SetReadLimit(4096)
|
ws.SetReadLimit(4096)
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
sessionID := RegisterOnlineSession("danmaku", clientIP, fromDisplay)
|
||||||
|
|
||||||
danmakuClientsMu.Lock()
|
danmakuClientsMu.Lock()
|
||||||
danmakuClients[ws] = struct{}{}
|
danmakuClients[ws] = clientIP
|
||||||
danmakuClientsMu.Unlock()
|
danmakuClientsMu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
UnregisterOnlineSession(sessionID)
|
||||||
danmakuClientsMu.Lock()
|
danmakuClientsMu.Lock()
|
||||||
delete(danmakuClients, ws)
|
delete(danmakuClients, ws)
|
||||||
danmakuClientsMu.Unlock()
|
danmakuClientsMu.Unlock()
|
||||||
@@ -91,6 +94,23 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
if mt != websocket.TextMessage {
|
if mt != websocket.TextMessage {
|
||||||
continue
|
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 {
|
if !canSend {
|
||||||
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
||||||
"type": "error",
|
"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()
|
vid := uuid.New().String()
|
||||||
vs := &viewerSession{id: vid, ws: ws}
|
vs := &viewerSession{id: vid, ws: ws}
|
||||||
|
sessionID := RegisterOnlineSession("viewer", c.ClientIP(), "***")
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.viewers[vid] = vs
|
h.viewers[vid] = vs
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
UnregisterOnlineSession(sessionID)
|
||||||
h.removeViewer(vid)
|
h.removeViewer(vid)
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
}()
|
}()
|
||||||
@@ -255,6 +257,7 @@ func handleViewerWS(c *gin.Context, h *Hub) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
TouchOnlineSession(sessionID)
|
||||||
var env wsEnvelope
|
var env wsEnvelope
|
||||||
if err := json.Unmarshal(data, &env); err != nil {
|
if err := json.Unmarshal(data, &env); err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user