Browse Source

fix: 修复出口屏MJPEG僵尸连接导致的全部接口超时

根因:Chromium 移除 <img>/删除 src 不会中止 multipart/x-mixed-replace 长流,
切换页面产生的僵尸连接逐次累积,占满浏览器同域 HTTP/1.1 并发上限(6条),
之后所有接口在浏览器侧排队,表现为全部超时(后端始终健康)。

- disconnectMonitor 在 <img> 仍连接文档时同步改写 src 为空图触发规范中止路径
- 中止时机放在 onBeforeRouteLeave 路由守卫(onDeactivated/onUnmounted 时 DOM
  已脱离文档,改写无效),并增加断流限量重连与事件代际守卫
- axios 超时 99999→30000;票机流 token 签发时顺手清理过期项
- 工作台联调面板:模拟入场/模拟出场识别/一键全流程(走真实管线)
lq 10 giờ trước cách đây
mục cha
commit
04c497baa9

+ 2 - 1
frontend/src/utils/request.js

@@ -6,7 +6,8 @@ import { ElLoading } from 'element-plus'
 
 const service = axios.create({
   baseURL: import.meta.env.VITE_BASE_API,
-  timeout: 99999
+  // 30s 覆盖所有常规接口;个别慢操作(道闸串口指令等)可在请求处单独覆盖 timeout。
+  timeout: 30000
 })
 let activeAxios = 0
 let timer

+ 130 - 18
frontend/src/view/parking/entryExit.vue

@@ -84,9 +84,10 @@
           </div>
           <div class="monitor-stage">
             <img
-              v-if="card.camera?.protocol === 'mjpeg' && card.camera?.play_url"
+              v-if="card.camera?.protocol === 'mjpeg' && monitorImageURLs[card.key]"
+              :ref="el => setMonitorImageRef(card.key, el)"
               class="monitor-video"
-              :src="card.camera.play_url"
+              :src="monitorImageURLs[card.key]"
               alt=""
               @load="handleMonitorReady(card.key)"
               @error="handleMonitorError(card.key)"
@@ -411,7 +412,7 @@
 </template>
 
 <script setup>
-import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
+import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, reactive, ref } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import {
   BottomRight,
@@ -433,6 +434,7 @@ import { obtainPullOverList } from '@/api/pullOver'
 import { getEnabledPaymentMethods, getPaymentEntryOptions } from '@/api/payment'
 import { t } from '@/lang/index.js'
 import { getUrl } from '@/utils/image'
+import { onBeforeRouteLeave } from 'vue-router'
 
 const queryForm = reactive({ plate_number: '', rfid_tag: '' })
 const entryForm = reactive({ parking_lot_id: null })
@@ -458,8 +460,10 @@ const monitorStatusByCode = reactive({})
 const monitorErrorByCode = reactive({})
 const monitorBusyByCode = reactive({})
 const monitorVideoRefs = new Map()
+const monitorImageRefs = new Map()
 const monitorPeers = new Map()
 const monitorGenerations = new Map()
+const monitorImageURLs = reactive({})
 
 const recentEvents = ref([])
 const lastEventID = ref(0)
@@ -467,6 +471,18 @@ const syncHealthy = ref(true)
 let eventTimer = null
 let scanBuf = ''
 let scanTimer = null
+let workbenchActive = false
+let workbenchRunID = 0
+
+// 1x1 透明 GIF。Chromium 在移除 <img> 或删除 src 属性时不会中止正在接收的
+// multipart/x-mixed-replace 流;只有把 src 改成其它地址才会触发规范定义的
+// 中止路径。离开页面时不换掉 src,僵尸流连接会逐次累积并占满浏览器对同一
+// 域名的 HTTP/1.1 并发上限(6 条),之后所有接口请求都会排队超时。
+const EMPTY_IMAGE = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
+const monitorRetryLimits = 3
+const monitorRetryDelayMs = 3000
+const monitorRetryCounts = new Map()
+const monitorRetryTimers = new Map()
 
 const contextAction = computed(() => contextData.value?.action || '')
 const currentVehicle = computed(() => contextData.value?.vehicle || null)
@@ -545,35 +561,72 @@ const channelMonitorCards = computed(() => {
 })
 const monitorOnlineCount = computed(() => channelMonitorCards.value.filter(card => card.device?.connected || card.camera?.online).length)
 
-onMounted(async () => {
-  await loadReferenceData()
-  await pollEvents()
-  eventTimer = setInterval(pollEvents, 3000)
-  document.addEventListener('keydown', onScanKey)
+onMounted(() => {
+  startWorkbench()
+})
+
+onActivated(() => {
+  startWorkbench()
+})
+
+onDeactivated(() => {
+  stopWorkbench()
 })
 
 onUnmounted(() => {
+  stopWorkbench()
+})
+
+// 路由守卫在组件 DOM 仍连接在文档上时触发;离开页面的流中止必须在这里做。
+// onDeactivated/onUnmounted 触发时 DOM 已脱离文档,改写 src 不会中止旧流,
+// 卸载路径下 Vue 甚至会丢弃还未提交的渲染更新。
+onBeforeRouteLeave(() => {
+  stopWorkbench()
+})
+
+async function startWorkbench() {
+  if (workbenchActive) return
+  workbenchActive = true
+  const runID = ++workbenchRunID
+  monitorRetryCounts.clear()
+  document.addEventListener('keydown', onScanKey)
+  await loadReferenceData(runID)
+  if (!workbenchActive || runID !== workbenchRunID) return
+  await pollEvents()
+  if (workbenchActive && runID === workbenchRunID && !eventTimer) eventTimer = setInterval(pollEvents, 3000)
+}
+
+function stopWorkbench() {
+  workbenchActive = false
+  workbenchRunID += 1
   clearInterval(eventTimer)
+  eventTimer = null
   clearTimeout(scanTimer)
+  scanBuf = ''
   document.removeEventListener('keydown', onScanKey)
+  monitorRetryTimers.forEach(timer => clearTimeout(timer))
+  monitorRetryTimers.clear()
+  monitorRetryCounts.clear()
   channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
-})
+}
 
-async function loadReferenceData() {
-  const [lotRes, entryRes, gateRes, cameraRes] = await Promise.all([
+async function loadReferenceData(runID) {
+  const [lotRes, entryRes, gateRes, cameraRes, methodsRes] = await Promise.all([
     obtainPullOverList({ page: 1, page_size: 100 }).catch(() => ({ code: 1, data: {} })),
     getPaymentEntryOptions().catch(() => ({ code: 1, data: [] })),
     getGateDevices().catch(() => ({ code: 1, data: [] })),
-    getCameraStreams().catch(() => ({ code: 1, data: [] }))
+    getCameraStreams().catch(() => ({ code: 1, data: [] })),
+    getEnabledPaymentMethods(paymentEntry).catch(() => ({ code: 1, data: [] }))
   ])
   parkingLotList.value = lotRes.code === 0 ? (lotRes.data?.list || []) : []
   paymentEntries.value = entryRes.code === 0 ? (entryRes.data || []) : []
   gateDevices.value = gateRes.code === 0 && Array.isArray(gateRes.data) ? gateRes.data : []
   cameras.value = cameraRes.code === 0 && Array.isArray(cameraRes.data) ? cameraRes.data : []
+  applyPaymentMethods(methodsRes)
   assignGateDevice()
   await nextTick()
+  if (!workbenchActive || runID !== workbenchRunID) return
   channelMonitorCards.value.forEach(card => connectMonitor(card))
-  await loadPaymentMethods()
 }
 
 async function loadPaymentMethods() {
@@ -584,16 +637,25 @@ async function loadPaymentMethods() {
   }
   try {
     const res = await getEnabledPaymentMethods(paymentEntry)
-    enabledPaymentMethods.value = res.code === 0 && Array.isArray(res.data) ? res.data : []
-    if (!enabledPaymentMethods.value.some(item => item.code === paymentMethod.value)) {
-      paymentMethod.value = enabledPaymentMethods.value[0]?.code || ''
-    }
+    applyPaymentMethods(res)
   } catch (error) {
     enabledPaymentMethods.value = []
     paymentMethod.value = ''
   }
 }
 
+function applyPaymentMethods(res) {
+  if (!paymentEntryAvailable.value) {
+    enabledPaymentMethods.value = []
+    paymentMethod.value = ''
+    return
+  }
+  enabledPaymentMethods.value = res.code === 0 && Array.isArray(res.data) ? res.data : []
+  if (!enabledPaymentMethods.value.some(item => item.code === paymentMethod.value)) {
+    paymentMethod.value = enabledPaymentMethods.value[0]?.code || ''
+  }
+}
+
 async function resolveContext() {
   const plateNumber = queryForm.plate_number.trim()
   const rfidTag = queryForm.rfid_tag.trim()
@@ -838,7 +900,6 @@ function clearCurrentVehicle() {
   contextError.value = ''
   entryForm.parking_lot_id = null
   selectedGateDeviceCode.value = ''
-  channelMonitorCards.value.forEach(card => disconnectMonitor(card.key))
   paymentMethod.value = enabledPaymentMethods.value[0]?.code || ''
   paidAmount.value = 0
 }
@@ -848,14 +909,51 @@ function setMonitorVideoRef(key, element) {
   else monitorVideoRefs.delete(key)
 }
 
+function setMonitorImageRef(key, element) {
+  if (element) monitorImageRefs.set(key, element)
+  else monitorImageRefs.delete(key)
+}
+
 function handleMonitorReady(key) {
+  if (monitorImageURLs[key] === EMPTY_IMAGE) return
   monitorStatusByCode[key] = 'playing'
   delete monitorErrorByCode[key]
+  monitorRetryCounts.delete(key)
 }
 
 function handleMonitorError(key) {
+  if (monitorImageURLs[key] === EMPTY_IMAGE) return
   monitorStatusByCode[key] = 'error'
   monitorErrorByCode[key] = t.value.workbenchCameraError
+  scheduleMonitorReconnect(key)
+}
+
+// 流意外中断(摄像头抖动、后端重启等)时限量重连,重连前重新拉取播放地址,
+// 因为旧地址里的短期令牌可能已经过期。
+function scheduleMonitorReconnect(key) {
+  if (!workbenchActive) return
+  const generation = monitorGenerations.get(key) || 0
+  const retries = monitorRetryCounts.get(key) || 0
+  if (retries >= monitorRetryLimits) return
+  monitorRetryCounts.set(key, retries + 1)
+  clearTimeout(monitorRetryTimers.get(key))
+  monitorRetryTimers.set(key, setTimeout(async () => {
+    monitorRetryTimers.delete(key)
+    if (!workbenchActive || generation !== monitorGenerations.get(key)) return
+    await refreshCameraStreams()
+    if (!workbenchActive || generation !== monitorGenerations.get(key)) return
+    const card = channelMonitorCards.value.find(item => item.key === key)
+    if (card?.camera?.play_url) connectMonitor(card)
+  }, monitorRetryDelayMs))
+}
+
+async function refreshCameraStreams() {
+  try {
+    const res = await getCameraStreams()
+    if (res.code === 0 && Array.isArray(res.data)) cameras.value = res.data
+  } catch (error) {
+    // 拉取失败时沿用旧配置,等待下一次重连机会
+  }
 }
 
 function monitorStatusLabel(card) {
@@ -886,12 +984,22 @@ function selectMonitorDevice(device) {
 
 function disconnectMonitor(key) {
   monitorGenerations.set(key, (monitorGenerations.get(key) || 0) + 1)
+  clearTimeout(monitorRetryTimers.get(key))
+  monitorRetryTimers.delete(key)
+  // 优先在仍连接的 <img> 上同步改写 src:这是浏览器唯一保证会中止
+  // multipart 长流的方式;删除属性、移除元素或响应式更新都不可靠。
+  const img = monitorImageRefs.get(key)
+  if (img) img.setAttribute('src', EMPTY_IMAGE)
+  if (monitorImageURLs[key] && monitorImageURLs[key] !== EMPTY_IMAGE) {
+    monitorImageURLs[key] = EMPTY_IMAGE
+  }
   const peer = monitorPeers.get(key)
   if (peer) peer.close()
   monitorPeers.delete(key)
   const video = monitorVideoRefs.get(key)
   if (video?.srcObject) video.srcObject = null
   monitorStatusByCode[key] = 'idle'
+  delete monitorErrorByCode[key]
 }
 
 async function connectMonitor(card) {
@@ -900,6 +1008,10 @@ async function connectMonitor(card) {
   const camera = card.camera
   if (!camera?.play_url) return
   monitorStatusByCode[card.key] = 'connecting'
+  if (camera.protocol === 'mjpeg') {
+    monitorImageURLs[card.key] = camera.play_url
+    return
+  }
   if (camera.protocol !== 'webrtc') {
     await nextTick()
     const video = monitorVideoRefs.get(card.key)

+ 273 - 61
internal/service/parking/camera_stream.go

@@ -20,7 +20,13 @@ import (
 	"wails-app/internal/global"
 )
 
-const cameraStreamTokenTTL = 2 * time.Minute
+const (
+	cameraStreamTokenTTL = 2 * time.Minute
+	// A stream has to emit data recently before it is considered online. The
+	// short grace window prevents an active MJPEG stream from briefly flipping
+	// to offline between browser refreshes.
+	cameraStreamOnlineTTL = 15 * time.Second
+)
 
 type cameraStreamToken struct {
 	DeviceCode string
@@ -28,6 +34,214 @@ type cameraStreamToken struct {
 }
 
 var cameraStreamTokens sync.Map
+var cameraLastFrameAt sync.Map
+
+// cameraMJPEGHub keeps one FFmpeg source per camera. Browser pages can create
+// multiple MJPEG connections during refreshes, route transitions, or when the
+// workbench is open in more than one window; those connections must not each
+// open a separate RTSP session and transcoder.
+var cameraMJPEGHub = newCameraStreamHub()
+
+type cameraStreamConfig struct {
+	DeviceCode string
+	SourceURL  string
+	FFmpegPath string
+	FPS        int
+	Quality    int
+}
+
+type cameraStreamSubscription struct {
+	frames      <-chan []byte
+	unsubscribe func()
+}
+
+type cameraStreamHub struct {
+	mu       sync.Mutex
+	sessions map[string]*cameraStreamSession
+}
+
+type cameraStreamSession struct {
+	hub    *cameraStreamHub
+	config cameraStreamConfig
+	ctx    context.Context
+	cancel context.CancelFunc
+
+	mu          sync.Mutex
+	nextID      uint64
+	subscribers map[uint64]chan []byte
+}
+
+func newCameraStreamHub() *cameraStreamHub {
+	return &cameraStreamHub{sessions: make(map[string]*cameraStreamSession)}
+}
+
+func newCameraStreamSession(hub *cameraStreamHub, config cameraStreamConfig) *cameraStreamSession {
+	ctx, cancel := context.WithCancel(context.Background())
+	return &cameraStreamSession{
+		hub:         hub,
+		config:      config,
+		ctx:         ctx,
+		cancel:      cancel,
+		subscribers: make(map[uint64]chan []byte),
+	}
+}
+
+func (h *cameraStreamHub) subscribe(config cameraStreamConfig) *cameraStreamSubscription {
+	h.mu.Lock()
+	session := h.sessions[config.DeviceCode]
+	created := session == nil
+	if session == nil {
+		session = newCameraStreamSession(h, config)
+		h.sessions[config.DeviceCode] = session
+	}
+	id, frames := session.addSubscriber()
+	if created {
+		go session.run()
+	}
+	h.mu.Unlock()
+	return &cameraStreamSubscription{
+		frames: frames,
+		unsubscribe: func() {
+			session.removeSubscriber(id)
+		},
+	}
+}
+
+func (h *cameraStreamHub) removeSession(session *cameraStreamSession) {
+	h.mu.Lock()
+	if h.sessions[session.config.DeviceCode] == session {
+		delete(h.sessions, session.config.DeviceCode)
+	}
+	h.mu.Unlock()
+}
+
+func (h *cameraStreamHub) stop(deviceCode string) {
+	h.mu.Lock()
+	session := h.sessions[deviceCode]
+	if session != nil {
+		delete(h.sessions, deviceCode)
+	}
+	h.mu.Unlock()
+	if session != nil {
+		session.cancel()
+	}
+}
+
+func (s *cameraStreamSession) addSubscriber() (uint64, <-chan []byte) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	s.nextID++
+	frames := make(chan []byte, 64)
+	s.subscribers[s.nextID] = frames
+	return s.nextID, frames
+}
+
+func (s *cameraStreamSession) removeSubscriber(id uint64) {
+	s.mu.Lock()
+	delete(s.subscribers, id)
+	empty := len(s.subscribers) == 0
+	s.mu.Unlock()
+	if empty {
+		s.hub.removeSession(s)
+		s.cancel()
+	}
+}
+
+func (s *cameraStreamSession) publish(frame []byte) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	for _, subscriber := range s.subscribers {
+		select {
+		case subscriber <- frame:
+		default:
+			// A slow client can resynchronize at the next MJPEG boundary. Never
+			// let it block the camera source or every other subscribed browser.
+		}
+	}
+}
+
+func (s *cameraStreamSession) closeSubscribers() {
+	s.mu.Lock()
+	for id, subscriber := range s.subscribers {
+		close(subscriber)
+		delete(s.subscribers, id)
+	}
+	s.mu.Unlock()
+}
+
+func (s *cameraStreamSession) run() {
+	defer s.closeSubscribers()
+	defer s.hub.removeSession(s)
+
+	args := []string{"-hide_banner", "-loglevel", "error"}
+	if strings.HasPrefix(strings.ToLower(s.config.SourceURL), "rtsp://") {
+		args = append(args, "-rtsp_transport", "tcp")
+	}
+	args = append(args,
+		"-i", s.config.SourceURL,
+		"-an",
+		"-vf", fmt.Sprintf("fps=%d", s.config.FPS),
+		"-q:v", fmt.Sprintf("%d", s.config.Quality),
+		"-f", "mpjpeg",
+		"pipe:1",
+	)
+
+	cmd := exec.CommandContext(s.ctx, s.config.FFmpegPath, args...)
+	if global.GVA_LOG != nil {
+		global.GVA_LOG.Info("启动共享摄像头 FFmpeg 转码", zap.String("device_code", s.config.DeviceCode), zap.String("ffmpeg", s.config.FFmpegPath), zap.String("source", redactCameraURL(s.config.SourceURL)))
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("创建摄像头 FFmpeg 输出失败", zap.String("device_code", s.config.DeviceCode), zap.Error(err))
+		}
+		return
+	}
+	cmd.Stderr = io.Discard
+	if err := cmd.Start(); err != nil {
+		if global.GVA_LOG != nil {
+			global.GVA_LOG.Error("摄像头 FFmpeg 启动失败", zap.String("device_code", s.config.DeviceCode), zap.String("ffmpeg", s.config.FFmpegPath), zap.Error(err))
+		}
+		return
+	}
+
+	buffer := make([]byte, 32*1024)
+	for {
+		n, readErr := stdout.Read(buffer)
+		if n > 0 {
+			frame := append([]byte(nil), buffer[:n]...)
+			markCameraStreamFrame(s.config.DeviceCode)
+			s.publish(frame)
+		}
+		if readErr != nil {
+			if !errors.Is(readErr, io.EOF) && s.ctx.Err() == nil && global.GVA_LOG != nil {
+				global.GVA_LOG.Warn("读取摄像头 FFmpeg 输出失败", zap.String("device_code", s.config.DeviceCode), zap.Error(readErr))
+			}
+			break
+		}
+	}
+	if err := cmd.Wait(); err != nil && s.ctx.Err() == nil && global.GVA_LOG != nil {
+		global.GVA_LOG.Error("摄像头 FFmpeg 转码进程退出", zap.String("device_code", s.config.DeviceCode), zap.Error(err))
+	}
+}
+
+func markCameraStreamFrame(deviceCode string) {
+	now := time.Now()
+	previous, hadPrevious := cameraLastFrameAt.Load(deviceCode)
+	cameraLastFrameAt.Store(deviceCode, now)
+	if (!hadPrevious || now.Sub(previous.(time.Time)) > cameraStreamOnlineTTL) && global.GVA_LOG != nil {
+		global.GVA_LOG.Info("摄像头视频流已在线", zap.String("device_code", deviceCode))
+	}
+}
+
+func hasRecentCameraStreamFrame(deviceCode string) bool {
+	value, ok := cameraLastFrameAt.Load(deviceCode)
+	if !ok {
+		return false
+	}
+	lastFrameAt, ok := value.(time.Time)
+	return ok && time.Since(lastFrameAt) <= cameraStreamOnlineTTL
+}
 
 func isSystemMJPEG(protocol string) bool {
 	protocol = strings.ToLower(strings.TrimSpace(protocol))
@@ -45,6 +259,15 @@ func issueCameraStreamToken(deviceCode string) string {
 			DeviceCode: deviceCode,
 			ExpiresAt:  time.Now().Add(cameraStreamTokenTTL),
 		})
+		// 每次页面刷新都会签发新令牌;过期项只在验证时才删除,长期运行会
+		// 无限累积,这里在签发时顺手清理。
+		now := time.Now()
+		cameraStreamTokens.Range(func(key, value any) bool {
+			if entry, ok := value.(cameraStreamToken); ok && now.After(entry.ExpiresAt) {
+				cameraStreamTokens.Delete(key)
+			}
+			return true
+		})
 		return token
 	}
 }
@@ -63,7 +286,7 @@ func validateCameraStreamToken(deviceCode, token string) bool {
 }
 
 // StreamCameraMJPEG 在系统端把摄像头原始 RTSP/HTTP 流转换为 multipart MJPEG。
-// 该输出可由 WebView 的 <img> 直接播放,不要求边缘端实现 WHEP
+// 同一摄像头的浏览器连接订阅同一个 FFmpeg 会话,避免重复转码耗尽主机资源
 func (s *PassageService) StreamCameraMJPEG(deviceCode, token string, writer http.ResponseWriter, ctx context.Context) error {
 	if !validateCameraStreamToken(deviceCode, token) {
 		return errors.New("摄像头播放令牌无效或已过期")
@@ -86,42 +309,15 @@ func (s *PassageService) StreamCameraMJPEG(deviceCode, token string, writer http
 		return errors.New("摄像头未配置原始视频地址")
 	}
 
-	ffmpegPath := strings.TrimSpace(global.GVA_CONFIG.Camera.FFmpegPath)
-	if ffmpegPath == "" {
-		ffmpegPath = "ffmpeg"
-	} else if info, statErr := os.Stat(ffmpegPath); statErr == nil && info.IsDir() {
-		// Accept a configured FFmpeg bin directory for compatibility with older config files.
-		ffmpegPath = filepath.Join(ffmpegPath, "ffmpeg.exe")
-	}
-	fps := global.GVA_CONFIG.Camera.MJPEGFPS
-	if fps < 1 || fps > 25 {
-		fps = 8
-	}
-	quality := global.GVA_CONFIG.Camera.JPEGQuality
-	if quality < 2 || quality > 31 {
-		quality = 5
-	}
-	args := []string{"-hide_banner", "-loglevel", "error"}
-	if strings.HasPrefix(strings.ToLower(sourceURL), "rtsp://") {
-		args = append(args, "-rtsp_transport", "tcp")
-	}
-	args = append(args, "-i", sourceURL, "-an", "-vf", fmt.Sprintf("fps=%d", fps), "-q:v", fmt.Sprintf("%d", quality), "-f", "mpjpeg", "pipe:1")
-
-	cmd := exec.CommandContext(ctx, ffmpegPath, args...)
-	if global.GVA_LOG != nil {
-		global.GVA_LOG.Info("启动摄像头 FFmpeg 转码", zap.String("device_code", deviceCode), zap.String("ffmpeg", ffmpegPath), zap.String("source", redactCameraURL(sourceURL)))
-	}
-	stdout, err := cmd.StdoutPipe()
-	if err != nil {
-		return fmt.Errorf("创建视频转换输出失败: %w", err)
-	}
-	cmd.Stderr = io.Discard
-	if err := cmd.Start(); err != nil {
-		if global.GVA_LOG != nil {
-			global.GVA_LOG.Error("摄像头 FFmpeg 启动失败", zap.String("device_code", deviceCode), zap.String("ffmpeg", ffmpegPath), zap.Error(err))
-		}
-		return fmt.Errorf("启动 FFmpeg 失败(%s): %w", ffmpegPath, err)
+	config := cameraStreamConfig{
+		DeviceCode: deviceCode,
+		SourceURL:  sourceURL,
+		FFmpegPath: resolveFFmpegPath(),
+		FPS:        configuredMJPEGFPS(),
+		Quality:    configuredJPEGQuality(),
 	}
+	subscription := cameraMJPEGHub.subscribe(config)
+	defer subscription.unsubscribe()
 
 	writer.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=ffmpeg")
 	writer.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
@@ -129,39 +325,55 @@ func (s *PassageService) StreamCameraMJPEG(deviceCode, token string, writer http
 	if flusher, ok := writer.(http.Flusher); ok {
 		flusher.Flush()
 	}
-	_, copyErr := io.Copy(&flushWriter{writer: writer}, stdout)
-	waitErr := cmd.Wait()
-	if copyErr != nil && !errors.Is(copyErr, context.Canceled) {
-		return fmt.Errorf("视频流传输失败: %w", copyErr)
+	for {
+		select {
+		case <-ctx.Done():
+			return nil
+		case frame, ok := <-subscription.frames:
+			if !ok {
+				return nil
+			}
+			if _, err := writer.Write(frame); err != nil {
+				return nil
+			}
+			if flusher, ok := writer.(http.Flusher); ok {
+				flusher.Flush()
+			}
+		}
 	}
-	if ctx.Err() != nil {
-		return nil
+}
+
+func resolveFFmpegPath() string {
+	ffmpegPath := strings.TrimSpace(global.GVA_CONFIG.Camera.FFmpegPath)
+	if ffmpegPath == "" {
+		return "ffmpeg"
 	}
-	if waitErr != nil {
-		if global.GVA_LOG != nil {
-			global.GVA_LOG.Error("摄像头 FFmpeg 转码进程退出", zap.String("device_code", deviceCode), zap.Error(waitErr))
-		}
-		return fmt.Errorf("FFmpeg 进程退出: %w", waitErr)
+	if info, err := os.Stat(ffmpegPath); err == nil && info.IsDir() {
+		return filepath.Join(ffmpegPath, "ffmpeg.exe")
 	}
-	return nil
+	return ffmpegPath
 }
 
-func redactCameraURL(raw string) string {
-	u, err := url.Parse(raw)
-	if err != nil || u.User == nil {
-		return raw
+func configuredMJPEGFPS() int {
+	fps := global.GVA_CONFIG.Camera.MJPEGFPS
+	if fps < 1 || fps > 25 {
+		return 8
 	}
-	return strings.Replace(raw, u.User.String()+"@", "***@", 1)
+	return fps
 }
 
-type flushWriter struct {
-	writer http.ResponseWriter
+func configuredJPEGQuality() int {
+	quality := global.GVA_CONFIG.Camera.JPEGQuality
+	if quality < 2 || quality > 31 {
+		return 5
+	}
+	return quality
 }
 
-func (w *flushWriter) Write(data []byte) (int, error) {
-	n, err := w.writer.Write(data)
-	if flusher, ok := w.writer.(http.Flusher); ok {
-		flusher.Flush()
+func redactCameraURL(raw string) string {
+	u, err := url.Parse(raw)
+	if err != nil || u.User == nil {
+		return raw
 	}
-	return n, err
+	return strings.Replace(raw, u.User.String()+"@", "***@", 1)
 }