Explorar o código

feat(video-platform): 新增工业视频平台模块后端与数据库表

按前端“工业视频平台”页面功能(通道管理、分屏预览、状态总览、识别抓拍、
通行与操作记录、视频分析事件)实现后端,并写入模拟演示数据。

数据库(迁移 0007_video_platform):
- video_channels:通道/设备树、国标编码、实时流地址与演示视频磁盘路径
- video_snapshots:识别抓拍图像元数据与图像磁盘路径
- video_analysis_events:视频分析事件与处理状态
- video_records:车辆/人员通行、风门未关严、人员轨迹、区域统计、设备操作等记录

接口(/api/v1/video-platform):
- 总览、模块配置、模块状态总览
- 通道 CRUD,视频流按库中磁盘路径流式返回并支持 Range,通道最近抓拍
- 识别抓拍检索、原图/缩略图,事件列表与状态更新,记录分页检索

演示数据:
- 18 路通道覆盖风门/局部风机/主风机三个模块,68 张抓拍、61 条记录、20 条事件
- 视频与图像取自 D:/workspace/js/gate_analysis_system(VIDEO_MEDIA_ROOT 可配置),
  表字段保存磁盘绝对路径,不复制文件
- scripts/seed_video_platform.py 支持对已有数据库幂等补种

测试:tests/test_video_platform_api.py 覆盖总览、状态指标、检索、
通道 CRUD、Range 视频流与缺失文件 404。
康宇 hai 3 semanas
pai
achega
468604b70d

+ 5 - 0
.env.example

@@ -49,3 +49,8 @@ CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:8080
 # 业务默认矿井
 DEFAULT_MINE_CODE=shangwan
 SEED_DEMO_DATA=true
+
+# 工业视频平台演示媒体根目录(视频/图像以磁盘路径存入业务表字段)
+# 默认指向风门/局扇/主扇识别演示系统的静态资源目录
+VIDEO_MEDIA_ROOT=D:/workspace/js/gate_analysis_system
+VIDEO_STREAM_CHUNK_SIZE=524288

+ 18 - 1
README.md

@@ -14,7 +14,7 @@
 
 ```text
 API 层
-  dashboard / documents / monitoring / knowledge
+  dashboard / documents / monitoring / video-platform / knowledge
   diagnosis / evaluations / work-orders / ai
 
 服务层
@@ -105,6 +105,23 @@ DEEPAGENT_MODEL=deepseek-flash
 
 初始迁移 `0001_initial` 创建当前全部表。后续结构变化应生成显式增量迁移。
 
+## 工业视频平台演示数据
+
+页面“工业视频平台”的后端数据保存在 `video_channels`、`video_snapshots`、
+`video_analysis_events`、`video_records` 四张表中,覆盖风门、局部风机、主风机及配电室三个模块:
+通道与设备树、分屏预览、状态总览、识别抓拍、通行与操作记录、视频分析事件。
+
+视频与图像文件不入库,表字段保存磁盘绝对路径,媒体根目录由 `VIDEO_MEDIA_ROOT` 指定
+(默认 `D:/workspace/js/gate_analysis_system`,即风门/局扇/主扇识别演示系统的公开资源目录)。
+接口 `/api/v1/video-platform/channels/{id}/stream` 按库中路径流式返回视频并支持 Range 拖动,
+`/api/v1/video-platform/snapshots/{id}/image` 返回抓拍原图。
+
+已有数据库补种视频平台演示数据(幂等,可重复执行):
+
+```powershell
+.\.venv\Scripts\python scripts\seed_video_platform.py
+```
+
 ## 测试
 
 ```powershell

+ 50 - 0
alembic/versions/0007_video_platform.py

@@ -0,0 +1,50 @@
+"""initial video platform schema
+
+Revision ID: 0007_video_platform
+Revises: 0006_processing_task_index
+Create Date: 2026-10-14
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+
+from alembic import op
+from app.db import models  # noqa: F401
+from app.db.base import Base
+from app.db.models.video import (
+    VideoAnalysisEvent,
+    VideoChannel,
+    VideoRecord,
+    VideoSnapshot,
+)
+
+
+revision = "0007_video_platform"
+down_revision = "0006_processing_task_index"
+branch_labels = None
+depends_on = None
+
+
+VIDEO_TABLES = (
+    VideoChannel.__table__,
+    VideoSnapshot.__table__,
+    VideoAnalysisEvent.__table__,
+    VideoRecord.__table__,
+)
+
+
+def upgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    existing = set(inspector.get_table_names())
+    missing = [table for table in VIDEO_TABLES if table.name not in existing]
+    if missing:
+        Base.metadata.create_all(bind=op.get_bind(), tables=missing)
+
+
+def downgrade() -> None:
+    inspector = sa.inspect(op.get_bind())
+    existing = set(inspector.get_table_names())
+    for table in reversed(VIDEO_TABLES):
+        if table.name in existing:
+            table.drop(bind=op.get_bind(), checkfirst=True)

+ 309 - 0
app/api/v1/endpoints/video_platform.py

@@ -0,0 +1,309 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, Query, Request, Response
+from fastapi.responses import FileResponse, StreamingResponse
+from sqlalchemy.orm import Session
+
+from app.db.session import get_db
+from app.schemas.common import Page
+from app.schemas.video import (
+    VideoAnalysisEventRead,
+    VideoChannelCreate,
+    VideoChannelRead,
+    VideoChannelUpdate,
+    VideoEventUpdate,
+    VideoModuleConfig,
+    VideoModuleSummaryRead,
+    VideoPlatformOverview,
+    VideoRecordRead,
+    VideoSnapshotRead,
+)
+from app.services import video_platform_service as video_service
+
+
+router = APIRouter(prefix="/video-platform", tags=["工业视频平台"])
+
+
+@router.get("/overview", response_model=VideoPlatformOverview, summary="视频平台总览")
+def overview(
+    mine_id: str = Query(...),
+    db: Session = Depends(get_db),
+):
+    return video_service.platform_overview(db, mine_id)
+
+
+@router.get("/modules", response_model=list[VideoModuleConfig], summary="模块页签与检索项配置")
+def modules(
+    mine_id: str = Query(...),
+    db: Session = Depends(get_db),
+):
+    return video_service.module_configs(db, mine_id)
+
+
+@router.get(
+    "/modules/{module}/summary",
+    response_model=VideoModuleSummaryRead,
+    summary="模块状态总览与统计卡片",
+)
+def module_summary(
+    module: str,
+    mine_id: str = Query(...),
+    db: Session = Depends(get_db),
+):
+    return video_service.module_summary(db, mine_id, module)
+
+
+# ---------------------------------------------------------------------------
+# 视频通道(通道管理、设备树、分屏预览)
+# ---------------------------------------------------------------------------
+
+
+@router.get("/channels", response_model=list[VideoChannelRead], summary="视频通道列表")
+def list_channels(
+    mine_id: str = Query(...),
+    module: str | None = Query(None, description="door/local_fan/main_fan"),
+    status: str | None = Query(None, description="online/offline/fault"),
+    keyword: str | None = Query(None, description="通道名称、安装地点或编码"),
+    limit: int = Query(200, ge=1, le=500),
+    db: Session = Depends(get_db),
+):
+    return video_service.list_channels(
+        db, mine_id, module=module, status=status, keyword=keyword, limit=limit
+    )
+
+
+@router.post(
+    "/channels",
+    response_model=VideoChannelRead,
+    status_code=201,
+    summary="新增视频通道",
+)
+def create_channel(payload: VideoChannelCreate, db: Session = Depends(get_db)):
+    return video_service.create_channel(db, payload)
+
+
+@router.get(
+    "/channels/{channel_id}",
+    response_model=VideoChannelRead,
+    summary="视频通道详情",
+)
+def channel_detail(channel_id: str, db: Session = Depends(get_db)):
+    return video_service.get_channel(db, channel_id)
+
+
+@router.patch(
+    "/channels/{channel_id}",
+    response_model=VideoChannelRead,
+    summary="更新视频通道",
+)
+def update_channel(
+    channel_id: str,
+    payload: VideoChannelUpdate,
+    db: Session = Depends(get_db),
+):
+    return video_service.update_channel(db, channel_id, payload)
+
+
+@router.delete(
+    "/channels/{channel_id}",
+    status_code=204,
+    summary="删除视频通道",
+)
+def delete_channel(channel_id: str, db: Session = Depends(get_db)) -> Response:
+    video_service.delete_channel(db, channel_id)
+    return Response(status_code=204)
+
+
+@router.get(
+    "/channels/{channel_id}/stream",
+    summary="通道视频流(读取数据库中的磁盘路径,支持 Range)",
+)
+def channel_stream(
+    channel_id: str,
+    request: Request,
+    db: Session = Depends(get_db),
+):
+    path, content_type = video_service.resolve_channel_media(channel_id, db)
+    file_size = path.stat().st_size
+    range_header = request.headers.get("range")
+    start, end = video_service.parse_range_header(range_header, file_size)
+    headers = {
+        "Accept-Ranges": "bytes",
+        "Content-Length": str(max(end - start + 1, 0)),
+        "Cache-Control": "no-store",
+    }
+    if range_header:
+        headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
+        return StreamingResponse(
+            video_service.iter_file_range(path, start, end),
+            status_code=206,
+            media_type=content_type,
+            headers=headers,
+        )
+    return StreamingResponse(
+        video_service.iter_file_range(path, start, end),
+        status_code=200,
+        media_type=content_type,
+        headers=headers,
+    )
+
+
+@router.get(
+    "/channels/{channel_id}/snapshot",
+    summary="通道最近识别抓拍图(抓图按钮)",
+)
+def channel_snapshot(channel_id: str, db: Session = Depends(get_db)):
+    path, content_type = video_service.resolve_channel_latest_snapshot(channel_id, db)
+    return FileResponse(path, media_type=content_type, filename=path.name)
+
+
+# ---------------------------------------------------------------------------
+# 识别抓拍
+# ---------------------------------------------------------------------------
+
+
+@router.get(
+    "/snapshots",
+    response_model=Page[VideoSnapshotRead],
+    summary="识别抓拍图像列表(时间、类型、设备检索)",
+)
+def list_snapshots(
+    mine_id: str = Query(...),
+    module: str | None = Query(None),
+    snapshot_type: str | None = Query(None, description="vehicle_passage/person_passage 等"),
+    event_type: str | None = Query(None, description="告警类型或操作类型名称"),
+    channel_id: str | None = Query(None),
+    start: datetime | None = Query(None, description="抓拍时间起"),
+    end: datetime | None = Query(None, description="抓拍时间止"),
+    keyword: str | None = Query(None, description="标题、车牌、人员或类型关键字"),
+    page: int = Query(1, ge=1),
+    page_size: int = Query(24, ge=1, le=200),
+    db: Session = Depends(get_db),
+):
+    items, total = video_service.list_snapshots(
+        db,
+        mine_id,
+        module=module,
+        snapshot_type=snapshot_type,
+        event_type=event_type,
+        channel_id=channel_id,
+        start=start,
+        end=end,
+        keyword=keyword,
+        page=page,
+        page_size=page_size,
+    )
+    return Page(items=items, total=total, page=page, page_size=page_size)
+
+
+@router.get(
+    "/snapshots/{snapshot_id}",
+    response_model=VideoSnapshotRead,
+    summary="识别抓拍详情",
+)
+def snapshot_detail(snapshot_id: str, db: Session = Depends(get_db)):
+    return video_service.get_snapshot(db, snapshot_id)
+
+
+@router.get("/snapshots/{snapshot_id}/image", summary="识别抓拍原图")
+def snapshot_image(snapshot_id: str, db: Session = Depends(get_db)):
+    path, content_type = video_service.resolve_snapshot_media(snapshot_id, db)
+    return FileResponse(path, media_type=content_type, filename=path.name)
+
+
+@router.get("/snapshots/{snapshot_id}/thumbnail", summary="识别抓拍缩略图")
+def snapshot_thumbnail(snapshot_id: str, db: Session = Depends(get_db)):
+    path, content_type = video_service.resolve_snapshot_thumbnail(snapshot_id, db)
+    return FileResponse(path, media_type=content_type, filename=path.name)
+
+
+# ---------------------------------------------------------------------------
+# 视频分析事件
+# ---------------------------------------------------------------------------
+
+
+@router.get(
+    "/events",
+    response_model=list[VideoAnalysisEventRead],
+    summary="视频分析事件与实时告警",
+)
+def list_events(
+    mine_id: str = Query(...),
+    module: str | None = Query(None),
+    level: str | None = Query(None, description="danger/warning/info"),
+    status: str | None = Query(None, description="pending/processing/handled/ignored"),
+    event_type: str | None = Query(None),
+    start: datetime | None = Query(None),
+    end: datetime | None = Query(None),
+    limit: int = Query(100, ge=1, le=1000),
+    db: Session = Depends(get_db),
+):
+    return video_service.list_events(
+        db,
+        mine_id,
+        module=module,
+        level=level,
+        status=status,
+        event_type=event_type,
+        start=start,
+        end=end,
+        limit=limit,
+    )
+
+
+@router.patch(
+    "/events/{event_id}",
+    response_model=VideoAnalysisEventRead,
+    summary="更新事件处理状态",
+)
+def update_event(
+    event_id: str,
+    payload: VideoEventUpdate,
+    db: Session = Depends(get_db),
+):
+    return video_service.update_event(db, event_id, payload)
+
+
+# ---------------------------------------------------------------------------
+# 通行与操作记录
+# ---------------------------------------------------------------------------
+
+
+@router.get(
+    "/records",
+    response_model=Page[VideoRecordRead],
+    summary="通行与操作记录列表",
+)
+def list_records(
+    mine_id: str = Query(...),
+    module: str | None = Query(None),
+    record_type: str | None = Query(
+        None,
+        description=(
+            "vehicle_passage/person_passage/door_unclosed/person_track/"
+            "area_stay/person_warn/person_detect/device_operation/device_warn"
+        ),
+    ),
+    channel_id: str | None = Query(None),
+    start: datetime | None = Query(None),
+    end: datetime | None = Query(None),
+    keyword: str | None = Query(None),
+    page: int = Query(1, ge=1),
+    page_size: int = Query(50, ge=1, le=200),
+    db: Session = Depends(get_db),
+):
+    items, total = video_service.list_records(
+        db,
+        mine_id,
+        module=module,
+        record_type=record_type,
+        channel_id=channel_id,
+        start=start,
+        end=end,
+        keyword=keyword,
+        page=page,
+        page_size=page_size,
+    )
+    return Page(items=items, total=total, page=page, page_size=page_size)

+ 2 - 0
app/api/v1/router.py

@@ -10,6 +10,7 @@ from app.api.v1.endpoints import (
     mines,
     monitoring,
     system,
+    video_platform,
     work_orders,
 )
 
@@ -20,6 +21,7 @@ api_router.include_router(dashboard.router)
 api_router.include_router(mines.router)
 api_router.include_router(documents.router)
 api_router.include_router(monitoring.router)
+api_router.include_router(video_platform.router)
 api_router.include_router(knowledge.router)
 api_router.include_router(diagnosis.router)
 api_router.include_router(evaluation.router)

+ 9 - 0
app/core/config.py

@@ -67,6 +67,10 @@ class Settings(BaseSettings):
     storage_capacity_gb: float = 1024.0
     seed_demo_data: bool = False
 
+    # 工业视频平台演示媒体根目录(视频/图像以磁盘路径存入业务表字段)
+    video_media_root: str = "D:/workspace/js/gate_analysis_system"
+    video_stream_chunk_size: int = 1024 * 512
+
     request_trace_header: str = "X-Trace-Id"
 
     @property
@@ -98,6 +102,11 @@ class Settings(BaseSettings):
         path = Path(self.upload_dir)
         return path if path.is_absolute() else PROJECT_ROOT / path
 
+    @property
+    def video_media_path(self) -> Path:
+        path = Path(self.video_media_root)
+        return path if path.is_absolute() else PROJECT_ROOT / path
+
     @property
     def model_configured(self) -> bool:
         return bool(self.openai_api_key)

+ 10 - 0
app/db/models/__init__.py

@@ -15,6 +15,12 @@ from app.db.models.evaluation import EvaluationIndicator, EvaluationRun, Evaluat
 from app.db.models.knowledge import KnowledgeChunk, KnowledgeItem
 from app.db.models.mine import Mine, MineArea, Roadway, WorkFace
 from app.db.models.monitoring import DataSource, Sensor, SensorReading, VentilationFacility
+from app.db.models.video import (
+    VideoAnalysisEvent,
+    VideoChannel,
+    VideoRecord,
+    VideoSnapshot,
+)
 from app.db.models.work_order import Alarm, WorkOrder, WorkOrderEvent
 
 __all__ = [
@@ -41,6 +47,10 @@ __all__ = [
     "Sensor",
     "SensorReading",
     "VentilationFacility",
+    "VideoAnalysisEvent",
+    "VideoChannel",
+    "VideoRecord",
+    "VideoSnapshot",
     "WorkFace",
     "WorkOrder",
     "WorkOrderEvent",

+ 194 - 0
app/db/models/video.py

@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import (
+    JSON,
+    BigInteger,
+    Boolean,
+    DateTime,
+    Float,
+    ForeignKey,
+    Integer,
+    String,
+)
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
+
+
+# 工业视频平台业务模块:风门 / 局部风机 / 主风机及配电室
+VIDEO_MODULE_DOOR = "door"
+VIDEO_MODULE_LOCAL_FAN = "local_fan"
+VIDEO_MODULE_MAIN_FAN = "main_fan"
+
+VIDEO_MODULES = (VIDEO_MODULE_DOOR, VIDEO_MODULE_LOCAL_FAN, VIDEO_MODULE_MAIN_FAN)
+
+
+class VideoChannel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+    """视频通道:设备树、分屏预览和实时视频对应的摄像机/视频流。
+
+    实时流地址保存在 ``stream_url``,演示视频以磁盘路径保存在 ``file_path``。
+    """
+
+    __tablename__ = "video_channels"
+
+    mine_id: Mapped[str] = mapped_column(
+        String(32), ForeignKey("mines.id", ondelete="CASCADE"), index=True
+    )
+    data_source_id: Mapped[str | None] = mapped_column(
+        String(32),
+        ForeignKey("data_sources.id", ondelete="SET NULL"),
+        nullable=True,
+        index=True,
+    )
+    code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    name: Mapped[str] = mapped_column(String(128))
+    module: Mapped[str] = mapped_column(String(32), index=True)
+    area_group: Mapped[str] = mapped_column(String(64), default="")
+    location: Mapped[str] = mapped_column(String(255), default="")
+    device_type: Mapped[str] = mapped_column(String(32), default="")
+    point_code: Mapped[str] = mapped_column(String(64), default="")
+    protocol: Mapped[str] = mapped_column(String(64), default="GB/T 28181")
+    stream_url: Mapped[str] = mapped_column(String(512), default="")
+    status: Mapped[str] = mapped_column(String(32), default="online", index=True)
+    file_path: Mapped[str] = mapped_column(String(512), default="")
+    file_name: Mapped[str] = mapped_column(String(255), default="")
+    file_size: Mapped[int] = mapped_column(BigInteger, default=0)
+    duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    resolution: Mapped[str] = mapped_column(String(32), default="")
+    frame_rate: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    preview_image_path: Mapped[str] = mapped_column(String(512), default="")
+    is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    sort_order: Mapped[int] = mapped_column(Integer, default=0)
+    last_online_at: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True), nullable=True
+    )
+    properties: Mapped[dict] = mapped_column(JSON, default=dict)
+
+
+class VideoSnapshot(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+    """AI 识别抓拍图像:车辆/人员通行、风门未关严、人员检测等。
+
+    ``file_path`` 保存图像在磁盘上的绝对路径,接口按路径读取原图。
+    """
+
+    __tablename__ = "video_snapshots"
+
+    mine_id: Mapped[str] = mapped_column(
+        String(32), ForeignKey("mines.id", ondelete="CASCADE"), index=True
+    )
+    channel_id: Mapped[str] = mapped_column(
+        String(32),
+        ForeignKey("video_channels.id", ondelete="CASCADE"),
+        index=True,
+    )
+    module: Mapped[str] = mapped_column(String(32), index=True)
+    snapshot_type: Mapped[str] = mapped_column(String(32), index=True)
+    event_type: Mapped[str] = mapped_column(String(64), default="", index=True)
+    level: Mapped[str] = mapped_column(String(16), default="info")
+    title: Mapped[str] = mapped_column(String(128), default="")
+    file_path: Mapped[str] = mapped_column(String(512), default="")
+    file_name: Mapped[str] = mapped_column(String(255), default="")
+    file_size: Mapped[int] = mapped_column(BigInteger, default=0)
+    width: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    height: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    thumbnail_path: Mapped[str] = mapped_column(String(512), default="")
+    plate_no: Mapped[str] = mapped_column(String(32), default="")
+    person_name: Mapped[str] = mapped_column(String(64), default="")
+    person_count: Mapped[int] = mapped_column(Integer, default=0)
+    team_name: Mapped[str] = mapped_column(String(64), default="")
+    gap_mm: Mapped[float | None] = mapped_column(Float, nullable=True)
+    confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
+    captured_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+    properties: Mapped[dict] = mapped_column(JSON, default=dict)
+
+
+class VideoAnalysisEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+    """视频智能分析事件与实时告警(防夹人、卡堵、密封失效、违规停留等)。"""
+
+    __tablename__ = "video_analysis_events"
+
+    mine_id: Mapped[str] = mapped_column(
+        String(32), ForeignKey("mines.id", ondelete="CASCADE"), index=True
+    )
+    channel_id: Mapped[str | None] = mapped_column(
+        String(32),
+        ForeignKey("video_channels.id", ondelete="SET NULL"),
+        nullable=True,
+        index=True,
+    )
+    snapshot_id: Mapped[str | None] = mapped_column(
+        String(32),
+        ForeignKey("video_snapshots.id", ondelete="SET NULL"),
+        nullable=True,
+        index=True,
+    )
+    module: Mapped[str] = mapped_column(String(32), index=True)
+    event_type: Mapped[str] = mapped_column(String(64), index=True)
+    category: Mapped[str] = mapped_column(String(32), default="")
+    level: Mapped[str] = mapped_column(String(16), default="info", index=True)
+    title: Mapped[str] = mapped_column(String(128))
+    description: Mapped[str] = mapped_column(String(512), default="")
+    confidence: Mapped[float | None] = mapped_column(Float, nullable=True)
+    status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
+    occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+    handled_at: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True), nullable=True
+    )
+    handled_by: Mapped[str] = mapped_column(String(64), default="")
+    handle_remark: Mapped[str] = mapped_column(String(255), default="")
+    properties: Mapped[dict] = mapped_column(JSON, default=dict)
+
+
+class VideoRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+    """通行与操作记录:三大模块底部记录面板的统一宽表。
+
+    ``record_type`` 取值:
+    ``vehicle_passage`` 车辆通行、``person_passage`` 人员通行、``door_unclosed`` 风门未关严、
+    ``person_track`` 人员轨迹、``area_stay`` 区域统计、``person_warn`` 预警记录、
+    ``person_detect`` 人员检测、``device_operation`` 设备操作、``device_warn`` 设备预警。
+    """
+
+    __tablename__ = "video_records"
+
+    mine_id: Mapped[str] = mapped_column(
+        String(32), ForeignKey("mines.id", ondelete="CASCADE"), index=True
+    )
+    channel_id: Mapped[str] = mapped_column(
+        String(32),
+        ForeignKey("video_channels.id", ondelete="CASCADE"),
+        index=True,
+    )
+    snapshot_id: Mapped[str | None] = mapped_column(
+        String(32),
+        ForeignKey("video_snapshots.id", ondelete="SET NULL"),
+        nullable=True,
+        index=True,
+    )
+    module: Mapped[str] = mapped_column(String(32), index=True)
+    record_type: Mapped[str] = mapped_column(String(32), index=True)
+    device_name: Mapped[str] = mapped_column(String(128), default="")
+    area_name: Mapped[str] = mapped_column(String(64), default="")
+    location: Mapped[str] = mapped_column(String(255), default="")
+    plate_no: Mapped[str] = mapped_column(String(32), default="")
+    person_name: Mapped[str] = mapped_column(String(64), default="")
+    person_count: Mapped[int] = mapped_column(Integer, default=0)
+    team_name: Mapped[str] = mapped_column(String(64), default="")
+    operation_type: Mapped[str] = mapped_column(String(64), default="")
+    compare_result: Mapped[str] = mapped_column(String(64), default="")
+    warn_type: Mapped[str] = mapped_column(String(64), default="")
+    result: Mapped[str] = mapped_column(String(64), default="")
+    handle_status: Mapped[str] = mapped_column(String(32), default="")
+    device_status: Mapped[str] = mapped_column(String(32), default="")
+    level: Mapped[str] = mapped_column(String(16), default="")
+    gap_mm: Mapped[float | None] = mapped_column(Float, nullable=True)
+    stay_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    enter_time: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True), nullable=True
+    )
+    leave_time: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True), nullable=True
+    )
+    occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
+    properties: Mapped[dict] = mapped_column(JSON, default=dict)

+ 338 - 0
app/db/repositories/video_platform.py

@@ -0,0 +1,338 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import desc, func, or_, select
+from sqlalchemy.orm import Session
+
+from app.db.models.video import (
+    VideoAnalysisEvent,
+    VideoChannel,
+    VideoRecord,
+    VideoSnapshot,
+)
+
+
+# ---------------------------------------------------------------------------
+# 视频通道
+# ---------------------------------------------------------------------------
+
+
+def list_channels(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    status: str | None = None,
+    keyword: str | None = None,
+    limit: int = 200,
+) -> list[VideoChannel]:
+    statement = select(VideoChannel).where(VideoChannel.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoChannel.module == module)
+    if status:
+        statement = statement.where(VideoChannel.status == status)
+    if keyword:
+        pattern = f"%{keyword.strip()}%"
+        statement = statement.where(
+            or_(
+                VideoChannel.name.like(pattern),
+                VideoChannel.location.like(pattern),
+                VideoChannel.code.like(pattern),
+            )
+        )
+    return list(
+        db.scalars(
+            statement.order_by(VideoChannel.sort_order, VideoChannel.code).limit(limit)
+        ).all()
+    )
+
+
+def get_channel(db: Session, channel_id: str) -> VideoChannel | None:
+    return db.get(VideoChannel, channel_id)
+
+
+def get_channel_by_code(db: Session, code: str) -> VideoChannel | None:
+    return db.scalar(select(VideoChannel).where(VideoChannel.code == code))
+
+
+def count_channels(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    status: str | None = None,
+) -> int:
+    statement = select(func.count(VideoChannel.id)).where(VideoChannel.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoChannel.module == module)
+    if status:
+        statement = statement.where(VideoChannel.status == status)
+    return int(db.scalar(statement) or 0)
+
+
+# ---------------------------------------------------------------------------
+# 识别抓拍图像
+# ---------------------------------------------------------------------------
+
+
+def list_snapshots(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    snapshot_type: str | None = None,
+    event_type: str | None = None,
+    channel_id: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    keyword: str | None = None,
+    page: int = 1,
+    page_size: int = 24,
+) -> tuple[list[VideoSnapshot], int]:
+    statement = select(VideoSnapshot).where(VideoSnapshot.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoSnapshot.module == module)
+    if snapshot_type:
+        statement = statement.where(VideoSnapshot.snapshot_type == snapshot_type)
+    if event_type:
+        statement = statement.where(VideoSnapshot.event_type == event_type)
+    if channel_id:
+        statement = statement.where(VideoSnapshot.channel_id == channel_id)
+    if start:
+        statement = statement.where(VideoSnapshot.captured_at >= start)
+    if end:
+        statement = statement.where(VideoSnapshot.captured_at <= end)
+    if keyword:
+        pattern = f"%{keyword.strip()}%"
+        statement = statement.where(
+            or_(
+                VideoSnapshot.title.like(pattern),
+                VideoSnapshot.plate_no.like(pattern),
+                VideoSnapshot.person_name.like(pattern),
+                VideoSnapshot.event_type.like(pattern),
+            )
+        )
+    statement = statement.order_by(desc(VideoSnapshot.captured_at))
+    total = int(
+        db.scalar(select(func.count()).select_from(statement.order_by(None).subquery()))
+        or 0
+    )
+    items = list(
+        db.scalars(
+            statement.offset((page - 1) * page_size).limit(page_size)
+        ).all()
+    )
+    return items, total
+
+
+def get_snapshot(db: Session, snapshot_id: str) -> VideoSnapshot | None:
+    return db.get(VideoSnapshot, snapshot_id)
+
+
+def latest_snapshot(db: Session, channel_id: str) -> VideoSnapshot | None:
+    return db.scalar(
+        select(VideoSnapshot)
+        .where(VideoSnapshot.channel_id == channel_id)
+        .order_by(desc(VideoSnapshot.captured_at))
+        .limit(1)
+    )
+
+
+def count_snapshots(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+) -> int:
+    statement = select(func.count(VideoSnapshot.id)).where(
+        VideoSnapshot.mine_id == mine_id
+    )
+    if module:
+        statement = statement.where(VideoSnapshot.module == module)
+    return int(db.scalar(statement) or 0)
+
+
+def distinct_snapshot_event_types(
+    db: Session,
+    mine_id: str,
+    module: str | None = None,
+) -> list[str]:
+    statement = select(VideoSnapshot.event_type).where(
+        VideoSnapshot.mine_id == mine_id,
+        VideoSnapshot.event_type != "",
+    )
+    if module:
+        statement = statement.where(VideoSnapshot.module == module)
+    rows = db.scalars(statement.distinct().order_by(VideoSnapshot.event_type)).all()
+    return [str(row) for row in rows]
+
+
+# ---------------------------------------------------------------------------
+# 视频分析事件
+# ---------------------------------------------------------------------------
+
+
+def list_events(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    level: str | None = None,
+    status: str | None = None,
+    event_type: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    limit: int = 100,
+) -> list[VideoAnalysisEvent]:
+    statement = select(VideoAnalysisEvent).where(VideoAnalysisEvent.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoAnalysisEvent.module == module)
+    if level:
+        statement = statement.where(VideoAnalysisEvent.level == level)
+    if status:
+        statement = statement.where(VideoAnalysisEvent.status == status)
+    if event_type:
+        statement = statement.where(VideoAnalysisEvent.event_type == event_type)
+    if start:
+        statement = statement.where(VideoAnalysisEvent.occurred_at >= start)
+    if end:
+        statement = statement.where(VideoAnalysisEvent.occurred_at <= end)
+    return list(
+        db.scalars(
+            statement.order_by(desc(VideoAnalysisEvent.occurred_at)).limit(limit)
+        ).all()
+    )
+
+
+def get_event(db: Session, event_id: str) -> VideoAnalysisEvent | None:
+    return db.get(VideoAnalysisEvent, event_id)
+
+
+def count_events(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    statuses: tuple[str, ...] | None = None,
+) -> int:
+    statement = select(func.count(VideoAnalysisEvent.id)).where(
+        VideoAnalysisEvent.mine_id == mine_id
+    )
+    if module:
+        statement = statement.where(VideoAnalysisEvent.module == module)
+    if statuses:
+        statement = statement.where(VideoAnalysisEvent.status.in_(statuses))
+    return int(db.scalar(statement) or 0)
+
+
+# ---------------------------------------------------------------------------
+# 通行与操作记录
+# ---------------------------------------------------------------------------
+
+
+def list_records(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    record_type: str | None = None,
+    channel_id: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    keyword: str | None = None,
+    page: int = 1,
+    page_size: int = 50,
+) -> tuple[list[VideoRecord], int]:
+    statement = select(VideoRecord).where(VideoRecord.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoRecord.module == module)
+    if record_type:
+        statement = statement.where(VideoRecord.record_type == record_type)
+    if channel_id:
+        statement = statement.where(VideoRecord.channel_id == channel_id)
+    if start:
+        statement = statement.where(VideoRecord.occurred_at >= start)
+    if end:
+        statement = statement.where(VideoRecord.occurred_at <= end)
+    if keyword:
+        pattern = f"%{keyword.strip()}%"
+        statement = statement.where(
+            or_(
+                VideoRecord.device_name.like(pattern),
+                VideoRecord.plate_no.like(pattern),
+                VideoRecord.person_name.like(pattern),
+                VideoRecord.area_name.like(pattern),
+                VideoRecord.operation_type.like(pattern),
+            )
+        )
+    statement = statement.order_by(desc(VideoRecord.occurred_at))
+    total = int(
+        db.scalar(select(func.count()).select_from(statement.order_by(None).subquery()))
+        or 0
+    )
+    items = list(
+        db.scalars(
+            statement.offset((page - 1) * page_size).limit(page_size)
+        ).all()
+    )
+    return items, total
+
+
+def count_records(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    record_type: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    in_progress_only: bool = False,
+) -> int:
+    statement = select(func.count(VideoRecord.id)).where(VideoRecord.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoRecord.module == module)
+    if record_type:
+        statement = statement.where(VideoRecord.record_type == record_type)
+    if start:
+        statement = statement.where(VideoRecord.occurred_at >= start)
+    if end:
+        statement = statement.where(VideoRecord.occurred_at <= end)
+    if in_progress_only:
+        statement = statement.where(VideoRecord.leave_time.is_(None))
+    return int(db.scalar(statement) or 0)
+
+
+def sum_person_count(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    record_type: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+) -> int:
+    statement = select(func.coalesce(func.sum(VideoRecord.person_count), 0)).where(
+        VideoRecord.mine_id == mine_id
+    )
+    if module:
+        statement = statement.where(VideoRecord.module == module)
+    if record_type:
+        statement = statement.where(VideoRecord.record_type == record_type)
+    if start:
+        statement = statement.where(VideoRecord.occurred_at >= start)
+    if end:
+        statement = statement.where(VideoRecord.occurred_at <= end)
+    return int(db.scalar(statement) or 0)
+
+
+def distinct_record_types(
+    db: Session,
+    mine_id: str,
+    module: str | None = None,
+) -> list[str]:
+    statement = select(VideoRecord.record_type).where(VideoRecord.mine_id == mine_id)
+    if module:
+        statement = statement.where(VideoRecord.module == module)
+    rows = db.scalars(statement.distinct().order_by(VideoRecord.record_type)).all()
+    return [str(row) for row in rows]

+ 5 - 0
app/db/seed.py

@@ -18,10 +18,13 @@ from app.db.models.monitoring import (
     VentilationFacility,
 )
 from app.db.models.work_order import Alarm, WorkOrder, WorkOrderEvent
+from app.db.seed_video_platform import seed_video_platform_demo
 
 
 def seed_demo_data(db: Session) -> None:
     if db.scalar(select(Mine).where(Mine.code == "shangwan")):
+        # 矿井主数据已存在时,只补种工业视频平台演示数据(内部幂等)。
+        seed_video_platform_demo(db)
         return
 
     now = datetime.now(timezone.utc)
@@ -505,4 +508,6 @@ def seed_demo_data(db: Session) -> None:
         )
     )
     db.commit()
+    # 工业视频平台演示数据(通道、抓拍、记录、分析事件)。
+    seed_video_platform_demo(db)
 

+ 1090 - 0
app/db/seed_video_platform.py

@@ -0,0 +1,1090 @@
+"""工业视频平台模拟演示数据。
+
+视频与图像取自 ``settings.video_media_root``(默认 D:/workspace/js/gate_analysis_system,
+即风门/局扇/主扇三个识别演示系统的公开资源目录),数据库只保存磁盘路径与业务属性。
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, time, timedelta, timezone
+
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.db.base import new_id
+from app.db.models.mine import Mine
+from app.db.models.monitoring import DataSource
+from app.db.models.video import (
+    VIDEO_MODULE_DOOR,
+    VIDEO_MODULE_LOCAL_FAN,
+    VIDEO_MODULE_MAIN_FAN,
+    VideoAnalysisEvent,
+    VideoChannel,
+    VideoRecord,
+    VideoSnapshot,
+)
+
+
+def _media_path(*parts: str) -> str:
+    """拼出媒体文件的磁盘绝对路径(不要求文件真实存在)。"""
+
+    return str(settings.video_media_path.joinpath(*parts))
+
+
+def _file_size(path: str) -> int:
+    try:
+        from pathlib import Path
+
+        file_path = Path(path)
+        if file_path.is_file():
+            return file_path.stat().st_size
+    except OSError:
+        return 0
+    return 0
+
+
+# (code, name, module, area_group, location, device_type, status,
+#  video_relative_path, resolution, preview_relative_path, extra_properties)
+CHANNEL_SPECS: list[tuple] = [
+    (
+        "VC-DOOR-01", "五盘区402辅运大巷风门", VIDEO_MODULE_DOOR, "风门区域",
+        "五盘区402辅运大巷", "door", "online",
+        ("public", "videos", "v1.mp4"), "1920x1080",
+        ("public", "images", "open.jpg"), {"door_side": "前门"},
+    ),
+    (
+        "VC-DOOR-02", "六盘区501胶带运输巷风门", VIDEO_MODULE_DOOR, "风门区域",
+        "六盘区501胶带运输巷", "door", "online",
+        ("public", "videos", "v2.mp4"), "1920x1080",
+        ("public", "images", "open1.jpg"), {"door_side": "后门"},
+    ),
+    (
+        "VC-DOOR-03", "七盘区602回风联络巷风门", VIDEO_MODULE_DOOR, "风门区域",
+        "七盘区602回风联络巷", "door", "online",
+        ("public", "videos", "v1.mp4"), "1920x1080",
+        ("public", "images", "gap1.jpg"), {"door_side": "前门"},
+    ),
+    (
+        "VC-DOOR-04", "中央变电所进风通道风门", VIDEO_MODULE_DOOR, "风门区域",
+        "中央变电所", "door", "online",
+        ("public", "videos", "v2.mp4"), "1920x1080",
+        ("public", "images", "person1.jpg"), {"door_side": "后门"},
+    ),
+    (
+        "VC-DOOR-05", "采区水泵房专用通道风门", VIDEO_MODULE_DOOR, "风门区域",
+        "采区水泵房", "door", "online",
+        ("public", "videos", "si.mp4"), "2560x1440",
+        ("public", "images", "car1.jpg"), {"door_side": "前门", "ai_anti_clip": True},
+    ),
+    (
+        "VC-DOOR-06", "南风井绕道处风门", VIDEO_MODULE_DOOR, "风门区域",
+        "南风井绕道", "door", "online",
+        ("public", "videos", "b1.mp4"), "1920x1080",
+        ("public", "images", "20260315_192656.jpg"), {"door_side": "前门"},
+    ),
+    (
+        "VC-DOOR-07", "12煤四盘区回风入口风门", VIDEO_MODULE_DOOR, "风门区域",
+        "12煤四盘区", "door", "fault",
+        ("public", "videos", "b2.mp4"), "1920x1080",
+        ("public", "images", "gap17.jpg"), {"fault_reason": "门体闭合反馈异常"},
+    ),
+    (
+        "VC-DOOR-08", "22107胶运绕道行车风门", VIDEO_MODULE_DOOR, "风门区域",
+        "22107胶运绕道", "door", "offline",
+        ("public", "videos", "4.mp4"), "1920x1080",
+        ("public", "images", "open15.jpg"), {"offline_reason": "网络中断"},
+    ),
+    (
+        "VC-LFAN-01", "1号掘进工作面局部风机-变频器区", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
+        "1号掘进工作面", "local_fan", "online",
+        ("public", "videos", "fan-main.mp4"), "1920x1080",
+        ("public", "images", "fan1.png"), {"area_name": "变频器区"},
+    ),
+    (
+        "VC-LFAN-02", "1号掘进工作面局部风机-配电柜区", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
+        "1号掘进工作面", "local_fan", "online",
+        ("public", "videos", "fan-main.mp4"), "1920x1080",
+        ("public", "images", "fan2.png"), {"area_name": "配电柜区"},
+    ),
+    (
+        "VC-LFAN-03", "2号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
+        "2号掘进工作面", "local_fan", "online",
+        ("public", "videos", "fan-main.mp4"), "1920x1080",
+        ("public", "images", "fan3.png"), {"area_name": "风机本体区"},
+    ),
+    (
+        "VC-LFAN-04", "3号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
+        "3号掘进工作面", "local_fan", "online",
+        ("public", "videos", "fan-main.mp4"), "1920x1080",
+        ("public", "images", "fan4.png"), {"area_name": "接线盒区"},
+    ),
+    (
+        "VC-LFAN-05", "4号掘进工作面局部风机", VIDEO_MODULE_LOCAL_FAN, "局扇区域",
+        "4号掘进工作面", "local_fan", "fault",
+        ("public", "videos", "fan-main.mp4"), "1920x1080",
+        ("public", "images", "fan5.png"), {"area_name": "散热区", "fault_reason": "风机振动超限"},
+    ),
+    (
+        "VC-MFAN-01", "一号主风机-风机主体", VIDEO_MODULE_MAIN_FAN, "主扇区域",
+        "一号主通风机", "main_fan", "online",
+        ("public", "videos", "f.mp4"), "1920x1080",
+        ("public", "images", "f1.png"), {"run_state": "running", "air_volume": 8600},
+    ),
+    (
+        "VC-MFAN-02", "一号主风机-配电室", VIDEO_MODULE_MAIN_FAN, "主扇区域",
+        "一号主风机配电室", "distribution_room", "online",
+        ("public", "videos", "p.mp4"), "1920x1080",
+        ("public", "images", "20260315_211358.jpg"), {"key_device": "PLC 配电柜"},
+    ),
+    (
+        "VC-MFAN-03", "二号主风机-风机主体", VIDEO_MODULE_MAIN_FAN, "主扇区域",
+        "二号主通风机", "main_fan", "online",
+        ("public", "videos", "f.mp4"), "1920x1080",
+        ("public", "images", "f2.png"), {"run_state": "stopped", "air_volume": 0},
+    ),
+    (
+        "VC-MFAN-04", "主扇配电室", VIDEO_MODULE_MAIN_FAN, "主扇区域",
+        "主通风机配电室", "distribution_room", "online",
+        ("public", "videos", "p.mp4"), "1920x1080",
+        ("public", "images", "20260315_213411.jpg"), {"key_device": "主扇变频柜"},
+    ),
+    (
+        "VC-MFAN-05", "主扇风硐监测点", VIDEO_MODULE_MAIN_FAN, "主扇区域",
+        "主通风机风硐", "main_fan", "online",
+        ("public", "videos", "s.mp4"), "1920x1080",
+        ("public", "images", "20260315_220400.jpg"), {"monitor_point": "风硐"},
+    ),
+]
+
+
+def _spot(now: datetime, minutes: int) -> datetime:
+    """now 往前 minutes 分钟,并保证落在“本地今天”之内(凌晨播种时自动收敛)。"""
+
+    local_now = now.astimezone()
+    local_start = datetime.combine(local_now.date(), time.min, tzinfo=local_now.tzinfo)
+    since_midnight = int((local_now - local_start).total_seconds() // 60)
+    capped = min(minutes, max(since_midnight - 2, 0))
+    return now - timedelta(minutes=capped)
+
+
+def seed_video_platform_demo(db: Session) -> dict:
+    """写入工业视频平台演示数据,已存在通道时跳过。"""
+
+    mine = db.scalar(
+        select(Mine).where(Mine.code == settings.default_mine_code)
+    ) or db.scalar(select(Mine).order_by(Mine.created_at).limit(1))
+    if mine is None:
+        return {
+            "created": False,
+            "reason": "mine_not_found",
+            "channels": 0,
+            "snapshots": 0,
+            "records": 0,
+            "events": 0,
+        }
+
+    existing = int(
+        db.scalar(
+            select(func.count(VideoChannel.id)).where(VideoChannel.mine_id == mine.id)
+        )
+        or 0
+    )
+    if existing > 0:
+        return {
+            "created": False,
+            "reason": "already_seeded",
+            "channels": existing,
+            "snapshots": int(
+                db.scalar(
+                    select(func.count(VideoSnapshot.id)).where(
+                        VideoSnapshot.mine_id == mine.id
+                    )
+                )
+                or 0
+            ),
+            "records": int(
+                db.scalar(
+                    select(func.count(VideoRecord.id)).where(
+                        VideoRecord.mine_id == mine.id
+                    )
+                )
+                or 0
+            ),
+            "events": int(
+                db.scalar(
+                    select(func.count(VideoAnalysisEvent.id)).where(
+                        VideoAnalysisEvent.mine_id == mine.id
+                    )
+                )
+                or 0
+            ),
+        }
+
+    now = datetime.now(timezone.utc)
+    data_source = _ensure_video_source(db, mine)
+    channels = _seed_channels(db, mine, data_source, now)
+    snapshots = _seed_snapshots(db, mine, channels, now)
+    records = _seed_records(db, mine, channels, snapshots, now)
+    events = _seed_events(db, mine, channels, snapshots, now)
+    db.commit()
+    return {
+        "created": True,
+        "mine_code": mine.code,
+        "channels": len(channels),
+        "snapshots": len(snapshots),
+        "records": len(records),
+        "events": len(events),
+        "media_root": str(settings.video_media_path),
+    }
+
+
+def _ensure_video_source(db: Session, mine: Mine) -> DataSource | None:
+    source = db.scalar(
+        select(DataSource).where(
+            DataSource.mine_id == mine.id, DataSource.source_type == "video"
+        )
+    )
+    if source is not None:
+        return source
+    source = db.scalar(
+        select(DataSource).where(
+            DataSource.mine_id == mine.id, DataSource.code == "VIDEO"
+        )
+    )
+    if source is not None:
+        return source
+    source = DataSource(
+        id=new_id(),
+        mine_id=mine.id,
+        code="VIDEO",
+        name="工业视频平台",
+        source_type="video",
+        protocol="GB/T 28181",
+        endpoint="10.20.18.10:5060",
+        status="online",
+        last_sync_at=datetime.now(timezone.utc),
+        point_count=218,
+        latency_ms=820,
+        config={"channels": 218, "media_root": str(settings.video_media_path)},
+    )
+    db.add(source)
+    db.flush()
+    return source
+
+
+def _seed_channels(
+    db: Session,
+    mine: Mine,
+    data_source: DataSource | None,
+    now: datetime,
+) -> dict[str, VideoChannel]:
+    channels: dict[str, VideoChannel] = {}
+    for index, spec in enumerate(CHANNEL_SPECS, start=1):
+        (
+            code, name, module, area_group, location, device_type, status,
+            video_parts, resolution, preview_parts, extra,
+        ) = spec
+        file_path = _media_path(*video_parts)
+        channel = VideoChannel(
+            id=new_id(),
+            mine_id=mine.id,
+            data_source_id=data_source.id if data_source else None,
+            code=code,
+            name=name,
+            module=module,
+            area_group=area_group,
+            location=location,
+            device_type=device_type,
+            point_code=f"3402000000132002{index:04d}",
+            protocol="GB/T 28181",
+            stream_url=f"rtsp://10.20.18.10:554/Streaming/Channels/{index:02d}01",
+            status=status,
+            file_path=file_path,
+            file_name=video_parts[-1],
+            file_size=_file_size(file_path),
+            duration_seconds=None,
+            resolution=resolution,
+            frame_rate=25,
+            preview_image_path=_media_path(*preview_parts),
+            is_enabled=True,
+            sort_order=index,
+            last_online_at=now - timedelta(seconds=30 * index)
+            if status == "online"
+            else now - timedelta(days=2),
+            properties=dict(extra),
+        )
+        db.add(channel)
+        channels[code] = channel
+    db.flush()
+    return channels
+
+
+def _snapshot(
+    *,
+    mine_id: str,
+    channel: VideoChannel,
+    snapshot_type: str,
+    event_type: str,
+    level: str,
+    image_parts: tuple[str, ...],
+    captured_at: datetime,
+    title: str = "",
+    plate_no: str = "",
+    person_name: str = "",
+    person_count: int = 0,
+    team_name: str = "",
+    gap_mm: float | None = None,
+    confidence: float | None = None,
+    properties: dict | None = None,
+) -> VideoSnapshot:
+    file_path = _media_path(*image_parts)
+    return VideoSnapshot(
+        id=new_id(),
+        mine_id=mine_id,
+        channel_id=channel.id,
+        module=channel.module,
+        snapshot_type=snapshot_type,
+        event_type=event_type,
+        level=level,
+        title=title or event_type,
+        file_path=file_path,
+        file_name=image_parts[-1],
+        file_size=_file_size(file_path),
+        width=None,
+        height=None,
+        thumbnail_path="",
+        plate_no=plate_no,
+        person_name=person_name,
+        person_count=person_count,
+        team_name=team_name,
+        gap_mm=gap_mm,
+        confidence=confidence,
+        captured_at=captured_at,
+        properties=properties or {},
+    )
+
+
+def _seed_snapshots(
+    db: Session,
+    mine: Mine,
+    channels: dict[str, VideoChannel],
+    now: datetime,
+) -> dict[str, VideoSnapshot]:
+    rows: list[VideoSnapshot] = []
+    by_key: dict[str, VideoSnapshot] = {}
+
+    door_codes = [f"VC-DOOR-{index:02d}" for index in range(1, 6)]
+    local_fan_codes = [f"VC-LFAN-{index:02d}" for index in range(1, 6)]
+    main_fan_codes = ["VC-MFAN-01", "VC-MFAN-02", "VC-MFAN-03", "VC-MFAN-04"]
+
+    # 1. 风门:车辆通行抓拍
+    vehicle_samples = [
+        ("car1.jpg", "蒙K3571", 12), ("car2.jpg", "蒙K6829", 29),
+        ("car3.jpg", "蒙K1122", 46), ("car4.jpg", "蒙K3344", 63),
+        ("car5.jpg", "蒙K5566", 80), ("car6.jpg", "蒙K7719", 97),
+        ("car7.jpg", "蒙K2288", 114), ("car8.jpg", "蒙K9966", 131),
+        ("car9.jpg", "蒙K4408", 148), ("car10.jpg", "蒙K5533", 165),
+        ("car11.jpg", "蒙K8842", 182), ("car12.jpg", "蒙K6317", 199),
+    ]
+    for index, (image, plate, minutes) in enumerate(vehicle_samples):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="vehicle_passage",
+            event_type="车辆通行",
+            level="info",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.location} 车辆通行 {plate}",
+            plate_no=plate,
+            confidence=0.96,
+        )
+        rows.append(snapshot)
+        by_key[f"vehicle-{index + 1}"] = snapshot
+
+    # 2. 风门:人员通行抓拍
+    person_samples = [
+        ("person1.jpg", "张三、李四", 2, "掘进一队", 20),
+        ("person2.jpg", "王五", 1, "机电队", 38),
+        ("person3.jpg", "赵六、陈七", 2, "回采一队", 55),
+        ("open1.jpg", "周八", 1, "机电队", 72),
+        ("open11.jpg", "吴九", 1, "维修队", 90),
+        ("open12.jpg", "郑十、刘一", 2, "掘进二队", 108),
+        ("open13.jpg", "孙十一", 1, "通风队", 126),
+        ("open14.jpg", "马十二", 1, "维修队", 145),
+    ]
+    for index, (image, person, count, team, minutes) in enumerate(person_samples):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="person_passage",
+            event_type="人员通行",
+            level="info",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.location} 人员通行 {person}",
+            person_name=person,
+            person_count=count,
+            team_name=team,
+            confidence=0.94,
+        )
+        rows.append(snapshot)
+        by_key[f"person-{index + 1}"] = snapshot
+
+    # 3. 风门:未关严抓拍
+    gap_samples = [
+        ("gap1.jpg", 15, 5), ("gap2.jpg", 10, 24), ("gap3.jpg", 12, 43),
+        ("gap4.jpg", 8, 60), ("gap5.jpg", 9, 78), ("gap6.jpg", 11, 96),
+        ("gap7.jpg", 14, 115), ("gap8.jpg", 7, 133), ("gap10.jpg", 16, 152),
+    ]
+    for index, (image, gap, minutes) in enumerate(gap_samples):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="door_unclosed",
+            event_type="风门未关严",
+            level="danger" if gap >= 15 else "warning",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.location} 门板闭合缝隙{gap}mm",
+            gap_mm=float(gap),
+            confidence=0.92,
+            properties={"threshold_mm": 10, "over_mm": max(gap - 10, 0)},
+        )
+        rows.append(snapshot)
+        by_key[f"unclosed-{index + 1}"] = snapshot
+
+    # 4. 风门:AI 分析类抓拍(来自 20260315 现场图像与门体照片)
+    analysis_samples = [
+        ("20260315_192656.jpg", "anti_clip", "AI防夹人预警", "danger", 8, {"area": "危险区域"}),
+        ("20260315_193111.jpg", "anti_clip", "AI防夹人预警", "danger", 34, {"area": "门体闭合区"}),
+        ("20260315_194758.jpg", "foreign_object", "异物卡堵预警", "warning", 50, {"object": "石块"}),
+        ("20260315_195803.jpg", "foreign_object", "异物卡堵预警", "warning", 86, {"object": "轨道杂物"}),
+        ("20260315_202803.jpg", "seal_failure", "密封失效故障", "warning", 104, {"pressure": "异常"}),
+        ("20260315_210425.jpg", "seal_failure", "密封失效故障", "warning", 128, {"pressure": "偏低"}),
+        ("20260315_211358.jpg", "illegal_stay", "违规停留预警", "info", 150, {"duration_minutes": 3}),
+        ("20260315_212805.jpg", "illegal_stay", "违规停留预警", "info", 170, {"duration_minutes": 2}),
+        ("20260315_213411.jpg", "vehicle_crash", "车辆撞击故障", "warning", 188, {"deform": "轻微"}),
+        ("20260315_220400.jpg", "patrol", "门体状态巡检", "info", 210, {"result": "门体正常"}),
+        ("open2.jpg", "patrol", "门体状态巡检", "info", 232, {"result": "闭锁装置正常"}),
+    ]
+    for index, (image, snapshot_type, event_type, level, minutes, extra) in enumerate(
+        analysis_samples
+    ):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type=snapshot_type,
+            event_type=event_type,
+            level=level,
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.location} {event_type}",
+            confidence=0.9,
+            properties=dict(extra),
+        )
+        rows.append(snapshot)
+        by_key[f"analysis-{index + 1}"] = snapshot
+
+    # 5. 局部风机:人员轨迹抓拍
+    track_samples = [
+        ("fan1.png", "张三", "机电队", 1, 12),
+        ("fan2.png", "李四", "维修队", 2, 30),
+        ("fan3.png", "王五", "掘进一队", 3, 48),
+        ("fan4.png", "赵六", "掘进二队", 4, 66),
+        ("fan5.png", "陈七", "回采一队", 5, 84),
+        ("fan6.png", "周八", "机电队", 1, 102),
+    ]
+    for index, (image, person, team, channel_index, minutes) in enumerate(track_samples):
+        channel = channels[local_fan_codes[channel_index - 1]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="person_track",
+            event_type="人员检测",
+            level="info",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.name} 人员检测 {person}",
+            person_name=person,
+            person_count=1,
+            team_name=team,
+            confidence=0.95,
+        )
+        rows.append(snapshot)
+        by_key[f"track-{index + 1}"] = snapshot
+
+    # 6. 局部风机:预警抓拍
+    warn_samples = [
+        ("fan7.png", "人员逗留", "张三", 4, {"area": "变频器区", "minutes": 9}),
+        ("fan3.png", "非法闯入", "外来人员", 26, {"area": "配电柜区"}),
+    ]
+    for index, (image, warn_type, person, minutes, extra) in enumerate(warn_samples):
+        channel = channels[local_fan_codes[index]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="person_track",
+            event_type=warn_type,
+            level="danger" if warn_type == "非法闯入" else "warning",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.name} {warn_type} {person}",
+            person_name=person,
+            person_count=1,
+            confidence=0.93,
+            properties=dict(extra),
+        )
+        rows.append(snapshot)
+        by_key[f"fan-warn-{index + 1}"] = snapshot
+
+    # 7. 局部风机:设备操作抓拍
+    fan_operations = [
+        ("风机调频", "张三", "人员准入匹配"),
+        ("切换主备", "未知人员", "未检测到人员"),
+        ("参数调整", "李四", "人员准入匹配"),
+        ("复位操作", "王五", "人员准入匹配"),
+        ("风机调频", "赵六", "人员准入匹配"),
+        ("参数调整", "外来人员", "未检测到人员"),
+        ("切换主备", "陈七", "人员准入匹配"),
+        ("风机调频", "周八", "人员准入匹配"),
+    ]
+    for index, (operation, operator, compare) in enumerate(fan_operations):
+        channel = channels[local_fan_codes[index % len(local_fan_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="device_operation",
+            event_type=operation,
+            level="info" if compare == "人员准入匹配" else "warning",
+            image_parts=("public", "images", f"fan{index % 7 + 1}.png"),
+            captured_at=_spot(now, 16 + index * 15),
+            title=f"{channel.name} {operation} {operator}",
+            person_name=operator if compare == "人员准入匹配" else "",
+            confidence=0.97,
+            properties={"operator": operator, "compare_result": compare},
+        )
+        rows.append(snapshot)
+        by_key[f"fan-op-{index + 1}"] = snapshot
+
+    # 8. 主风机:人员检测抓拍
+    detect_samples = [
+        ("f1.png", "VC-MFAN-01", "风机主体", "检测到人员", "正常", 10),
+        ("f2.png", "VC-MFAN-02", "设备操作区", "检测到人员", "正常", 18),
+        ("f3.png", "VC-MFAN-03", "风机主体", "检测到人员", "异常", 35),
+        ("f1.png", "VC-MFAN-01", "风机主体", "检测到人员", "正常", 52),
+        ("f2.png", "VC-MFAN-02", "设备操作区", "检测到人员", "正常", 70),
+        ("f3.png", "VC-MFAN-04", "配电柜", "未检测到人员", "异常", 88),
+    ]
+    for index, (image, code, spot, result, status, minutes) in enumerate(detect_samples):
+        channel = channels[code]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="person_detect",
+            event_type="人员检测",
+            level="info" if status == "正常" else "warning",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.name} {result}",
+            confidence=0.94,
+            properties={"location": spot, "detect_result": result, "device_status": status},
+        )
+        rows.append(snapshot)
+        by_key[f"detect-{index + 1}"] = snapshot
+
+    # 9. 主风机:设备操作与预警抓拍
+    main_operations = [
+        ("启动风机", "一号主风机", "PLC操作匹配", 22),
+        ("参数调整", "主扇配电室", "PLC操作匹配", 44),
+        ("停止风机", "二号主风机", "未检测到人员", 76),
+        ("巡检操作", "主扇风硐监测点", "PLC操作匹配", 98),
+    ]
+    for index, (operation, device, compare, minutes) in enumerate(main_operations):
+        channel = channels[main_fan_codes[index % len(main_fan_codes)]]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="device_operation",
+            event_type=operation,
+            level="info" if compare == "PLC操作匹配" else "warning",
+            image_parts=("public", "images", f"f{index % 3 + 1}.png"),
+            captured_at=_spot(now, minutes),
+            title=f"{device} {operation}",
+            confidence=0.96,
+            properties={"device": device, "compare_result": compare},
+        )
+        rows.append(snapshot)
+        by_key[f"main-op-{index + 1}"] = snapshot
+
+    main_warns = [
+        ("f2.png", "VC-MFAN-01", "设备断网", 40),
+        ("f3.png", "VC-MFAN-02", "设备断网", 92),
+    ]
+    for index, (image, code, warn_type, minutes) in enumerate(main_warns):
+        channel = channels[code]
+        snapshot = _snapshot(
+            mine_id=mine.id,
+            channel=channel,
+            snapshot_type="device_warn",
+            event_type=warn_type,
+            level="warning",
+            image_parts=("public", "images", image),
+            captured_at=_spot(now, minutes),
+            title=f"{channel.name} {warn_type}",
+            confidence=0.9,
+            properties={"warn_type": warn_type},
+        )
+        rows.append(snapshot)
+        by_key[f"main-warn-{index + 1}"] = snapshot
+
+    db.add_all(rows)
+    db.flush()
+    return by_key
+
+
+def _seed_records(
+    db: Session,
+    mine: Mine,
+    channels: dict[str, VideoChannel],
+    snapshots: dict[str, VideoSnapshot],
+    now: datetime,
+) -> list[VideoRecord]:
+    rows: list[VideoRecord] = []
+    door_codes = [f"VC-DOOR-{index:02d}" for index in range(1, 6)]
+
+    # 车辆通行记录
+    vehicle_plates = [
+        ("蒙K3571", 12), ("蒙K6829", 29), ("蒙K1122", 46), ("蒙K3344", 63),
+        ("蒙K5566", 80), ("蒙K7719", 97), ("蒙K2288", 114), ("蒙K9966", 131),
+        ("蒙K4408", 148), ("蒙K5533", 165), ("蒙K8842", 182), ("蒙K6317", 199),
+    ]
+    for index, (plate, minutes) in enumerate(vehicle_plates):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = snapshots.get(f"vehicle-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_DOOR,
+                record_type="vehicle_passage",
+                device_name=channel.name,
+                location=channel.location,
+                plate_no=plate,
+                person_count=0,
+                result="通行成功",
+                device_status="正常",
+                occurred_at=_spot(now, minutes),
+                properties={},
+            )
+        )
+
+    # 人员通行记录
+    person_rows = [
+        ("张三、李四", 2, "掘进一队", 20, "正常通行"),
+        ("王五", 1, "机电队", 38, "正常通行"),
+        ("赵六、陈七", 2, "回采一队", 55, "正常通行"),
+        ("周八", 1, "机电队", 72, "正常通行"),
+        ("吴九", 1, "维修队", 90, "正常通行"),
+        ("郑十、刘一", 2, "掘进二队", 108, "正常通行"),
+        ("孙十一", 1, "通风队", 126, "正常通行"),
+        ("马十二", 1, "维修队", 145, "正常通行"),
+    ]
+    for index, (person, count, team, minutes, result) in enumerate(person_rows):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = snapshots.get(f"person-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_DOOR,
+                record_type="person_passage",
+                device_name=channel.name,
+                location=channel.location,
+                person_name=person,
+                person_count=count,
+                team_name=team,
+                result=result,
+                device_status="正常",
+                occurred_at=_spot(now, minutes),
+                properties={},
+            )
+        )
+
+    # 风门未关严记录
+    gap_rows = [
+        (15, 5), (10, 24), (12, 43), (8, 60), (9, 78), (11, 96), (14, 115), (7, 133), (16, 152)
+    ]
+    for index, (gap, minutes) in enumerate(gap_rows):
+        channel = channels[door_codes[index % len(door_codes)]]
+        snapshot = snapshots.get(f"unclosed-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_DOOR,
+                record_type="door_unclosed",
+                device_name=channel.name,
+                location=channel.location,
+                gap_mm=float(gap),
+                level="严重" if gap >= 15 else "一般",
+                device_status="异常",
+                occurred_at=_spot(now, minutes),
+                properties={"threshold_mm": 10},
+            )
+        )
+
+    # 局部风机:人员轨迹(最后两条未离开,用于“实时人数”)
+    local_fan_codes = [f"VC-LFAN-{index:02d}" for index in range(1, 6)]
+    track_rows = [
+        ("张三", "机电队", "变频器区", 96, 96 - 8, 500, None),
+        ("李四", "维修队", "配电柜区", 84, 84 - 5, 310, None),
+        ("王五", "掘进一队", "风机本体区", 210, 200, 600, None),
+        ("赵六", "掘进二队", "接线盒区", 260, 250, 480, None),
+        ("陈七", "回采一队", "散热区", 330, 322, 420, None),
+        ("周八", "机电队", "变频器区", 380, 372, 360, None),
+        ("洪七", "维修队", "配电柜区", 24, None, None, "onsite"),
+        ("孙九", "机电队", "风机本体区", 14, None, None, "onsite"),
+    ]
+    for index, (person, team, area, enter_minutes, leave_minutes, stay, onsite) in enumerate(
+        track_rows
+    ):
+        channel = channels[local_fan_codes[index % len(local_fan_codes)]]
+        snapshot = snapshots.get(f"track-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_LOCAL_FAN,
+                record_type="person_track",
+                device_name=channel.name,
+                area_name=area,
+                location=channel.location,
+                person_name=person,
+                person_count=1,
+                team_name=team,
+                enter_time=_spot(now, enter_minutes),
+                leave_time=_spot(now, leave_minutes) if leave_minutes else None,
+                stay_seconds=stay,
+                device_status="在场" if onsite else "已离开",
+                occurred_at=_spot(now, enter_minutes),
+                properties={"stay_text": _stay_text(stay)},
+            )
+        )
+
+    # 局部风机:区域停留统计
+    area_rows = [
+        ("变频器区", 2, "8分20秒", "3分45秒", "6分02秒", "12分05秒"),
+        ("配电柜区", 1, "5分10秒", "5分10秒", "5分10秒", "5分10秒"),
+        ("风机本体区", 0, "0秒", "0秒", "0秒", "0秒"),
+        ("接线盒区", 0, "0秒", "0秒", "0秒", "0秒"),
+        ("散热区", 0, "0秒", "0秒", "0秒", "0秒"),
+    ]
+    for index, (area, count, max_s, min_s, avg_s, total_s) in enumerate(area_rows):
+        channel = channels[local_fan_codes[index % len(local_fan_codes)]]
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=None,
+                module=VIDEO_MODULE_LOCAL_FAN,
+                record_type="area_stay",
+                device_name=channel.name,
+                area_name=area,
+                location=channel.location,
+                person_count=count,
+                occurred_at=_spot(now, 60),
+                properties={
+                    "max_stay": max_s,
+                    "min_stay": min_s,
+                    "avg_stay": avg_s,
+                    "total_stay": total_s,
+                },
+            )
+        )
+
+    # 局部风机:预警记录
+    fan_warn_rows = [
+        ("person_warn", "张三", "变频器区", "人员逗留", 30, "处理中"),
+        ("person_warn", "外来人员", "配电柜区", "非法闯入", 26, "未处理"),
+        ("person_warn", "李四", "配电柜区", "人员逗留", 120, "已处理"),
+    ]
+    for index, (record_type, person, area, warn_type, minutes, handle) in enumerate(
+        fan_warn_rows
+    ):
+        channel = channels[local_fan_codes[index % len(local_fan_codes)]]
+        snapshot = snapshots.get(f"fan-warn-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_LOCAL_FAN,
+                record_type=record_type,
+                device_name=channel.name,
+                area_name=area,
+                location=channel.location,
+                person_name=person,
+                person_count=1,
+                warn_type=warn_type,
+                handle_status=handle,
+                occurred_at=_spot(now, minutes),
+                properties={"region": area},
+            )
+        )
+
+    # 主风机:人员检测记录
+    detect_rows = [
+        ("VC-MFAN-01", "风机主体", "检测到人员", "正常", 10),
+        ("VC-MFAN-02", "设备操作区", "检测到人员", "正常", 18),
+        ("VC-MFAN-03", "风机主体", "检测到人员", "异常", 35),
+        ("VC-MFAN-01", "风机主体", "检测到人员", "正常", 52),
+        ("VC-MFAN-02", "设备操作区", "检测到人员", "正常", 70),
+        ("VC-MFAN-04", "配电柜", "未检测到人员", "异常", 88),
+    ]
+    for index, (code, spot, result, device_status, minutes) in enumerate(detect_rows):
+        channel = channels[code]
+        snapshot = snapshots.get(f"detect-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_MAIN_FAN,
+                record_type="person_detect",
+                device_name=channel.name,
+                location=spot,
+                result=result,
+                device_status=device_status,
+                occurred_at=_spot(now, minutes),
+                properties={},
+            )
+        )
+
+    # 主风机:设备操作日志
+    main_op_rows = [
+        ("VC-MFAN-01", "一号主风机", "启动风机", "PLC操作匹配", "正常", 22),
+        ("VC-MFAN-02", "主扇配电室", "参数调整", "PLC操作匹配", "正常", 44),
+        ("VC-MFAN-03", "二号主风机", "停止风机", "未检测到人员", "异常", 76),
+        ("VC-MFAN-05", "主扇风硐监测点", "巡检操作", "PLC操作匹配", "正常", 98),
+        ("VC-MFAN-01", "一号主风机", "参数调整", "PLC操作匹配", "正常", 150),
+        ("VC-MFAN-04", "主扇配电室", "巡检操作", "PLC操作匹配", "正常", 180),
+    ]
+    for index, (code, device, operation, compare, status, minutes) in enumerate(
+        main_op_rows
+    ):
+        channel = channels[code]
+        snapshot = snapshots.get(f"main-op-{index + 1}")
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_MAIN_FAN,
+                record_type="device_operation",
+                device_name=device,
+                location=channel.location,
+                operation_type=operation,
+                compare_result=compare,
+                device_status=status,
+                occurred_at=_spot(now, minutes),
+                properties={},
+            )
+        )
+
+    # 主风机:设备预警记录
+    main_warn_rows = [
+        ("VC-MFAN-01", "风机主体", "设备断网", 40, "已处理"),
+        ("VC-MFAN-02", "配电柜", "设备断网", 92, "处理中"),
+        ("VC-MFAN-01", "风机主体", "轴温异常", 130, "未处理"),
+        ("VC-MFAN-05", "风硐", "巡检缺失", 200, "已处理"),
+    ]
+    for index, (code, spot, warn_type, minutes, handle) in enumerate(main_warn_rows):
+        channel = channels[code]
+        snapshot = snapshots.get(f"main-warn-{index + 1}") if index < 2 else None
+        rows.append(
+            VideoRecord(
+                id=new_id(),
+                mine_id=mine.id,
+                channel_id=channel.id,
+                snapshot_id=snapshot.id if snapshot else None,
+                module=VIDEO_MODULE_MAIN_FAN,
+                record_type="device_warn",
+                device_name=channel.name,
+                location=spot,
+                warn_type=warn_type,
+                handle_status=handle,
+                occurred_at=_spot(now, minutes),
+                properties={},
+            )
+        )
+
+    db.add_all(rows)
+    db.flush()
+    return rows
+
+
+def _seed_events(
+    db: Session,
+    mine: Mine,
+    channels: dict[str, VideoChannel],
+    snapshots: dict[str, VideoSnapshot],
+    now: datetime,
+) -> list[VideoAnalysisEvent]:
+    rows: list[VideoAnalysisEvent] = []
+
+    door_events = [
+        ("VC-DOOR-02", "AI防夹人预警", "danger", "危险区域有人闯入,已触发PLC停止", 6, "pending", "analysis-1"),
+        ("VC-DOOR-04", "风门未关严", "warning", "门板闭合缝隙15mm,超标10mm", 22, "processing", "unclosed-1"),
+        ("VC-DOOR-01", "违规停留预警", "info", "禁停区有车辆滞留3分钟,未及时驶离", 41, "handled", "analysis-7"),
+        ("VC-DOOR-03", "密封失效故障", "warning", "疑似风门密封胶条脱落,存在漏风风险", 58, "pending", "analysis-5"),
+        ("VC-DOOR-05", "车辆撞击故障", "warning", "风门门体疑似受到轻微撞击,需检查变形", 75, "processing", "analysis-9"),
+        ("VC-DOOR-06", "异物卡堵预警", "warning", "门体通道检测到石块异物,影响闭合", 94, "pending", "analysis-3"),
+        ("VC-DOOR-07", "AI防夹人预警", "danger", "风门闭合区检测到人员肢体,紧急止停", 118, "handled", "analysis-2"),
+        ("VC-DOOR-03", "风门未关严", "warning", "门板闭合缝隙16mm,超标11mm", 140, "pending", "unclosed-9"),
+        ("VC-DOOR-08", "违规停留预警", "info", "禁停区有人员逗留,已语音提醒", 165, "ignored", "analysis-8"),
+        ("VC-DOOR-06", "异物卡堵预警", "warning", "风门轨道有杂物卡滞", 190, "handled", "analysis-4"),
+    ]
+    for code, event_type, level, description, minutes, status, snap_key in door_events:
+        channel = channels[code]
+        snapshot = snapshots.get(snap_key)
+        rows.append(
+            _event(
+                mine=mine,
+                channel=channel,
+                snapshot=snapshot,
+                event_type=event_type,
+                category="风门",
+                level=level,
+                title=f"{channel.name} {event_type}",
+                description=description,
+                status=status,
+                minutes=minutes,
+                now=now,
+                confidence=0.93,
+            )
+        )
+
+    fan_events = [
+        ("VC-LFAN-01", "人员逗留预警", "warning", "张三维修队变频器区停留超8分钟", 30, "processing", "fan-warn-1"),
+        ("VC-LFAN-02", "非法闯入预警", "danger", "外来人员进入配电柜区", 26, "pending", "fan-warn-2"),
+        ("VC-LFAN-04", "未检测到人员", "danger", "3号风机切换主备未检测到操作人员", 70, "pending", "fan-op-2"),
+        ("VC-LFAN-05", "风机离线预警", "info", "4号局部风机通讯中断", 210, "handled", "fan-op-6"),
+        ("VC-LFAN-03", "人员超时逗留", "warning", "人员配电柜区停留时间超限", 122, "handled", "track-3"),
+    ]
+    for code, event_type, level, description, minutes, status, snap_key in fan_events:
+        channel = channels[code]
+        snapshot = snapshots.get(snap_key)
+        rows.append(
+            _event(
+                mine=mine,
+                channel=channel,
+                snapshot=snapshot,
+                event_type=event_type,
+                category="局部通风",
+                level=level,
+                title=f"{channel.name} {event_type}",
+                description=description,
+                status=status,
+                minutes=minutes,
+                now=now,
+                confidence=0.91,
+            )
+        )
+
+    main_events = [
+        ("VC-MFAN-01", "设备断网", "warning", "一号主风机通讯中断5分钟", 40, "handled", "main-warn-1"),
+        ("VC-MFAN-03", "未检测到人员", "danger", "二号主风机停止风机操作未检测到人员", 76, "pending", "main-op-3"),
+        ("VC-MFAN-04", "设备断网", "warning", "主扇配电室网络抖动", 92, "processing", "main-warn-2"),
+        ("VC-MFAN-01", "轴温异常", "warning", "一号主风机轴承温度 78℃ 超限", 130, "pending", "detect-3"),
+        ("VC-MFAN-05", "巡检缺失", "info", "风硐监测点巡检记录缺失", 200, "ignored", None),
+    ]
+    for code, event_type, level, description, minutes, status, snap_key in main_events:
+        channel = channels[code]
+        snapshot = snapshots.get(snap_key or "")
+        rows.append(
+            _event(
+                mine=mine,
+                channel=channel,
+                snapshot=snapshot,
+                event_type=event_type,
+                category="主通风机",
+                level=level,
+                title=f"{channel.name} {event_type}",
+                description=description,
+                status=status,
+                minutes=minutes,
+                now=now,
+                confidence=0.92,
+            )
+        )
+
+    db.add_all(rows)
+    db.flush()
+    return rows
+
+
+def _event(
+    *,
+    mine: Mine,
+    channel: VideoChannel,
+    snapshot: VideoSnapshot | None,
+    event_type: str,
+    category: str,
+    level: str,
+    title: str,
+    description: str,
+    status: str,
+    minutes: int,
+    now: datetime,
+    confidence: float,
+) -> VideoAnalysisEvent:
+    occurred_at = _spot(now, minutes)
+    handled_at = None
+    handled_by = ""
+    if status in {"handled", "ignored"}:
+        handled_at = min(occurred_at + timedelta(minutes=8), now)
+        handled_by = "通防科值班员"
+    return VideoAnalysisEvent(
+        id=new_id(),
+        mine_id=mine.id,
+        channel_id=channel.id,
+        snapshot_id=snapshot.id if snapshot else None,
+        module=channel.module,
+        event_type=event_type,
+        category=category,
+        level=level,
+        title=title,
+        description=description,
+        confidence=confidence,
+        status=status,
+        occurred_at=occurred_at,
+        handled_at=handled_at,
+        handled_by=handled_by,
+        handle_remark="已通知现场复核" if handled_at else "",
+        properties={"source": "video_ai"},
+    )
+
+
+def _stay_text(stay_seconds: int | None) -> str:
+    if not stay_seconds:
+        return ""
+    minutes, seconds = divmod(int(stay_seconds), 60)
+    return f"{minutes}分{seconds:02d}秒"

+ 255 - 0
app/schemas/video.py

@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+from app.schemas.common import ORMModel
+
+
+# ---------------------------------------------------------------------------
+# 视频通道
+# ---------------------------------------------------------------------------
+
+
+class VideoChannelCreate(BaseModel):
+    mine_id: str
+    code: str
+    name: str
+    module: str
+    area_group: str = ""
+    location: str = ""
+    device_type: str = ""
+    point_code: str = ""
+    protocol: str = "GB/T 28181"
+    stream_url: str = ""
+    status: str = "online"
+    file_path: str = ""
+    preview_image_path: str = ""
+    resolution: str = ""
+    frame_rate: int | None = None
+    is_enabled: bool = True
+    sort_order: int = 0
+    properties: dict = Field(default_factory=dict)
+
+
+class VideoChannelUpdate(BaseModel):
+    name: str | None = None
+    module: str | None = None
+    area_group: str | None = None
+    location: str | None = None
+    device_type: str | None = None
+    point_code: str | None = None
+    protocol: str | None = None
+    stream_url: str | None = None
+    status: str | None = None
+    file_path: str | None = None
+    preview_image_path: str | None = None
+    resolution: str | None = None
+    frame_rate: int | None = None
+    is_enabled: bool | None = None
+    sort_order: int | None = None
+    properties: dict | None = None
+
+
+class VideoChannelRead(ORMModel):
+    id: str
+    mine_id: str
+    data_source_id: str | None
+    code: str
+    name: str
+    module: str
+    area_group: str
+    location: str
+    device_type: str
+    point_code: str
+    protocol: str
+    stream_url: str
+    status: str
+    file_path: str
+    file_name: str
+    file_size: int
+    duration_seconds: int | None
+    resolution: str
+    frame_rate: int | None
+    preview_image_path: str
+    is_enabled: bool
+    sort_order: int
+    last_online_at: datetime | None
+    properties: dict
+    has_video: bool = False
+    media_available: bool = False
+    playback_url: str = ""
+    snapshot_url: str = ""
+
+
+# ---------------------------------------------------------------------------
+# 识别抓拍图像
+# ---------------------------------------------------------------------------
+
+
+class VideoSnapshotRead(ORMModel):
+    id: str
+    mine_id: str
+    channel_id: str
+    channel_name: str = ""
+    module: str
+    snapshot_type: str
+    event_type: str
+    level: str
+    title: str
+    file_path: str
+    file_name: str
+    file_size: int
+    width: int | None
+    height: int | None
+    thumbnail_path: str
+    plate_no: str
+    person_name: str
+    person_count: int
+    team_name: str
+    gap_mm: float | None
+    confidence: float | None
+    captured_at: datetime
+    properties: dict
+    media_available: bool = False
+    image_url: str = ""
+    thumbnail_url: str = ""
+
+
+# ---------------------------------------------------------------------------
+# 视频分析事件
+# ---------------------------------------------------------------------------
+
+
+class VideoEventUpdate(BaseModel):
+    status: str | None = None
+    handled_by: str | None = None
+    handle_remark: str | None = None
+
+
+class VideoAnalysisEventRead(ORMModel):
+    id: str
+    mine_id: str
+    channel_id: str | None
+    channel_name: str = ""
+    snapshot_id: str | None
+    module: str
+    event_type: str
+    category: str
+    level: str
+    title: str
+    description: str
+    confidence: float | None
+    status: str
+    occurred_at: datetime
+    handled_at: datetime | None
+    handled_by: str
+    handle_remark: str
+    properties: dict
+    snapshot_image_url: str = ""
+
+
+# ---------------------------------------------------------------------------
+# 通行与操作记录
+# ---------------------------------------------------------------------------
+
+
+class VideoRecordRead(ORMModel):
+    id: str
+    mine_id: str
+    channel_id: str
+    device_name: str
+    module: str
+    record_type: str
+    record_type_label: str = ""
+    area_name: str
+    location: str
+    plate_no: str
+    person_name: str
+    person_count: int
+    team_name: str
+    operation_type: str
+    compare_result: str
+    warn_type: str
+    result: str
+    handle_status: str
+    device_status: str
+    level: str
+    gap_mm: float | None
+    stay_seconds: int | None
+    enter_time: datetime | None
+    leave_time: datetime | None
+    occurred_at: datetime
+    snapshot_id: str | None
+    snapshot_image_url: str = ""
+    properties: dict
+
+
+# ---------------------------------------------------------------------------
+# 页面配置与总览
+# ---------------------------------------------------------------------------
+
+
+class VideoRecordTab(BaseModel):
+    record_type: str
+    label: str
+    columns: list[str]
+
+
+class VideoModuleConfig(BaseModel):
+    key: str
+    title: str
+    status_title: str
+    selector_label: str
+    type_filter_label: str
+    type_filter_options: list[str]
+    scope_filter_label: str
+    scope_filter_options: list[str]
+    snapshot_types: list[str]
+    record_tabs: list[VideoRecordTab]
+
+
+class VideoSummaryCard(BaseModel):
+    key: str
+    label: str
+    value: int | float
+    unit: str = ""
+    hint: str = ""
+    tone: str = "default"
+
+
+class VideoStatusMetric(BaseModel):
+    key: str
+    label: str
+    value: int | float
+    unit: str = ""
+    tone: str = "normal"
+
+
+class VideoPlatformOverview(BaseModel):
+    mine_id: str
+    channel_total: int
+    channel_configured: int
+    channel_online: int
+    channel_offline: int
+    channel_fault: int
+    event_open_count: int
+    snapshot_count: int
+    modules: list[VideoModuleConfig]
+    summary: list[VideoSummaryCard]
+
+
+class VideoStatusOverview(BaseModel):
+    mine_id: str
+    module: str
+    status_title: str
+    metrics: list[VideoStatusMetric]
+
+
+class VideoModuleSummaryRead(BaseModel):
+    module: str
+    title: str
+    status_title: str
+    metrics: list[VideoStatusMetric]
+    summary: list[VideoSummaryCard]

+ 757 - 0
app/services/video_platform_service.py

@@ -0,0 +1,757 @@
+from __future__ import annotations
+
+import mimetypes
+from collections.abc import Iterator
+from datetime import datetime, time, timedelta, timezone
+from pathlib import Path
+
+from sqlalchemy import delete, update
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from app.core.errors import APIError
+from app.db.models.video import (
+    VIDEO_MODULE_DOOR,
+    VIDEO_MODULE_LOCAL_FAN,
+    VIDEO_MODULE_MAIN_FAN,
+    VideoAnalysisEvent,
+    VideoChannel,
+    VideoRecord,
+    VideoSnapshot,
+)
+from app.db.repositories import video_platform as video_repo
+from app.schemas.video import (
+    VideoChannelCreate,
+    VideoChannelUpdate,
+    VideoEventUpdate,
+)
+
+
+OPEN_EVENT_STATUSES = ("pending", "processing")
+
+# 前端页面每个模块的固定文案与记录页签定义(列名与页面表格一致)。
+# type_filter_options / scope_filter_options / snapshot_types 由数据库数据动态生成。
+MODULE_DEFINITIONS: dict[str, dict] = {
+    VIDEO_MODULE_DOOR: {
+        "title": "风门视频图像综合解析系统",
+        "status_title": "风门状态总览",
+        "selector_label": "风门选择",
+        "type_filter_label": "告警类型",
+        "record_tabs": [
+            ("vehicle_passage", "车辆通行记录", ["序号", "风门名称", "车牌号", "通行时间", "结果"]),
+            ("person_passage", "人员通行记录", ["序号", "风门名称", "人员信息", "通行时间", "结果"]),
+            ("door_unclosed", "风门未关严记录", ["序号", "风门名称", "检测时间", "缝隙(mm)", "等级"]),
+        ],
+    },
+    VIDEO_MODULE_LOCAL_FAN: {
+        "title": "局部风机无人值守演示系统",
+        "status_title": "风机状态总览",
+        "selector_label": "风机选择",
+        "type_filter_label": "操作类型",
+        "record_tabs": [
+            ("person_track", "人员轨迹", ["序号", "风机", "区域", "姓名", "区队", "进入", "离开", "时长"]),
+            ("area_stay", "区域统计", ["序号", "区域", "人数", "最长停留", "最短停留", "平均停留", "累计停留"]),
+            ("person_warn", "预警记录", ["序号", "风机", "区域", "姓名", "预警类型", "预警时间", "处理状态"]),
+        ],
+    },
+    VIDEO_MODULE_MAIN_FAN: {
+        "title": "主通风机无人巡检演示系统",
+        "status_title": "主风机运行状态",
+        "selector_label": "设备选择",
+        "type_filter_label": "操作类型",
+        "record_tabs": [
+            ("person_detect", "人员检测记录", ["序号", "设备", "位置", "检测时间", "检测结果", "状态"]),
+            ("device_operation", "设备操作日志", ["序号", "设备", "操作类型", "操作时间", "人员比对", "状态"]),
+            ("device_warn", "设备预警记录", ["序号", "设备", "位置", "预警类型", "预警时间", "处理状态"]),
+        ],
+    },
+}
+
+RECORD_TYPE_LABELS: dict[str, str] = {
+    record_type: label
+    for definition in MODULE_DEFINITIONS.values()
+    for record_type, label, _ in definition["record_tabs"]
+}
+
+MODULE_TITLES: dict[str, str] = {
+    key: str(value["title"]) for key, value in MODULE_DEFINITIONS.items()
+}
+
+
+# ---------------------------------------------------------------------------
+# 通用工具
+# ---------------------------------------------------------------------------
+
+
+def _now() -> datetime:
+    return datetime.now(timezone.utc)
+
+
+def utc_day_bounds(day: datetime | None = None) -> tuple[datetime, datetime]:
+    """返回本地自然日对应的 UTC 起止时间,用于“今日车辆/今日人员”等统计。"""
+
+    local_now = (day or _now()).astimezone()
+    local_start = datetime.combine(local_now.date(), time.min, tzinfo=local_now.tzinfo)
+    local_end = local_start + timedelta(days=1)
+    return local_start.astimezone(timezone.utc), local_end.astimezone(timezone.utc)
+
+
+def _file_meta(path: str) -> tuple[bool, int]:
+    if not path:
+        return False, 0
+    try:
+        file_path = Path(path)
+        if file_path.is_file():
+            return True, file_path.stat().st_size
+    except OSError:
+        return False, 0
+    return False, 0
+
+
+def _api_path(path: str) -> str:
+    return f"{settings.api_v1_prefix}{path}"
+
+
+def parse_range_header(range_header: str | None, file_size: int) -> tuple[int, int]:
+    """解析 HTTP Range 请求头,返回闭区间 ``(start, end)``。
+
+    仅支持单段 ``bytes=start-end`` 形式;非法或越界时返回完整区间。
+    """
+
+    if not range_header or not range_header.startswith("bytes=") or file_size <= 0:
+        return 0, max(file_size - 1, 0)
+    spec = range_header.split("=", 1)[1].split(",", 1)[0].strip()
+    if "-" not in spec:
+        return 0, max(file_size - 1, 0)
+    start_text, _, end_text = spec.partition("-")
+    try:
+        if start_text:
+            start = int(start_text)
+            end = int(end_text) if end_text else file_size - 1
+        else:  # bytes=-N 表示最后 N 字节
+            length = int(end_text or "0")
+            if length <= 0:
+                return 0, max(file_size - 1, 0)
+            start = max(file_size - length, 0)
+            end = file_size - 1
+    except ValueError:
+        return 0, max(file_size - 1, 0)
+    start = max(start, 0)
+    end = min(end, file_size - 1)
+    if start > end:
+        return 0, max(file_size - 1, 0)
+    return start, end
+
+
+def iter_file_range(path: Path, start: int, end: int) -> Iterator[bytes]:
+    """按块读取磁盘文件的闭区间内容,供视频流式响应使用。"""
+
+    chunk_size = max(int(settings.video_stream_chunk_size), 64 * 1024)
+    remaining = end - start + 1
+    with path.open("rb") as handle:
+        handle.seek(start)
+        while remaining > 0:
+            chunk = handle.read(min(chunk_size, remaining))
+            if not chunk:
+                break
+            remaining -= len(chunk)
+            yield chunk
+
+
+def guess_content_type(path: str) -> str:
+    content_type, _ = mimetypes.guess_type(path)
+    return content_type or "application/octet-stream"
+
+
+# ---------------------------------------------------------------------------
+# 序列化
+# ---------------------------------------------------------------------------
+
+
+def _channel_payload(channel: VideoChannel) -> dict:
+    exists, size = _file_meta(channel.file_path or channel.stream_url)
+    payload = {
+        column.name: getattr(channel, column.name)
+        for column in VideoChannel.__table__.columns
+    }
+    payload.update(
+        {
+            "file_size": channel.file_size or size,
+            "has_video": bool(channel.stream_url or channel.file_path),
+            "media_available": exists,
+            "playback_url": _api_path(f"/video-platform/channels/{channel.id}/stream"),
+            "snapshot_url": _api_path(f"/video-platform/channels/{channel.id}/snapshot"),
+        }
+    )
+    return payload
+
+
+def _snapshot_payload(
+    snapshot: VideoSnapshot,
+    *,
+    channel_name: str = "",
+) -> dict:
+    exists, size = _file_meta(snapshot.file_path)
+    payload = {
+        column.name: getattr(snapshot, column.name)
+        for column in VideoSnapshot.__table__.columns
+    }
+    payload.update(
+        {
+            "file_size": snapshot.file_size or size,
+            "channel_name": channel_name,
+            "media_available": exists,
+            "image_url": _api_path(f"/video-platform/snapshots/{snapshot.id}/image"),
+            "thumbnail_url": _api_path(
+                f"/video-platform/snapshots/{snapshot.id}/thumbnail"
+            ),
+        }
+    )
+    return payload
+
+
+def _event_payload(event: VideoAnalysisEvent, *, channel_name: str = "") -> dict:
+    payload = {
+        column.name: getattr(event, column.name)
+        for column in VideoAnalysisEvent.__table__.columns
+    }
+    payload.update(
+        {
+            "channel_name": channel_name,
+            "snapshot_image_url": (
+                _api_path(f"/video-platform/snapshots/{event.snapshot_id}/image")
+                if event.snapshot_id
+                else ""
+            ),
+        }
+    )
+    return payload
+
+
+def _record_payload(record: VideoRecord) -> dict:
+    payload = {
+        column.name: getattr(record, column.name)
+        for column in VideoRecord.__table__.columns
+    }
+    payload["record_type_label"] = RECORD_TYPE_LABELS.get(record.record_type, "")
+    payload["snapshot_image_url"] = (
+        _api_path(f"/video-platform/snapshots/{record.snapshot_id}/image")
+        if record.snapshot_id
+        else ""
+    )
+    return payload
+
+
+def _channel_names(db: Session, channels: list[VideoChannel]) -> dict[str, str]:
+    return {channel.id: channel.name for channel in channels}
+
+
+# ---------------------------------------------------------------------------
+# 页面配置 / 总览 / 状态指标
+# ---------------------------------------------------------------------------
+
+
+def module_configs(db: Session, mine_id: str) -> list[dict]:
+    channels = video_repo.list_channels(db, mine_id)
+    configs: list[dict] = []
+    for module, definition in MODULE_DEFINITIONS.items():
+        module_channels = [item for item in channels if item.module == module]
+        event_types = video_repo.distinct_snapshot_event_types(db, mine_id, module)
+        configs.append(
+            {
+                "key": module,
+                "title": definition["title"],
+                "status_title": definition["status_title"],
+                "selector_label": definition["selector_label"],
+                "type_filter_label": definition["type_filter_label"],
+                "type_filter_options": ["全部类型", *event_types],
+                "scope_filter_label": definition["selector_label"],
+                "scope_filter_options": [
+                    "全部设备",
+                    *[item.name for item in module_channels],
+                ],
+                "snapshot_types": event_types,
+                "record_tabs": [
+                    {
+                        "record_type": record_type,
+                        "label": label,
+                        "columns": columns,
+                    }
+                    for record_type, label, columns in definition["record_tabs"]
+                ],
+            }
+        )
+    return configs
+
+
+def platform_overview(db: Session, mine_id: str) -> dict:
+    channel_total = video_repo.count_channels(db, mine_id)
+    channel_online = video_repo.count_channels(db, mine_id, status="online")
+    channel_offline = video_repo.count_channels(db, mine_id, status="offline")
+    channel_fault = video_repo.count_channels(db, mine_id, status="fault")
+    event_open_count = video_repo.count_events(
+        db, mine_id, statuses=OPEN_EVENT_STATUSES
+    )
+    snapshot_count = video_repo.count_snapshots(db, mine_id)
+    summary = [
+        {
+            "key": "channels",
+            "label": "视频通道",
+            "value": channel_total,
+            "unit": "路",
+            "hint": "视频平台接入总量",
+            "tone": "default",
+        },
+        {
+            "key": "configured",
+            "label": "已配置通道",
+            "value": channel_total,
+            "unit": "路",
+            "hint": "通道管理可维护通道",
+            "tone": "online",
+        },
+        {
+            "key": "online",
+            "label": "在线通道",
+            "value": channel_online,
+            "unit": "路",
+            "hint": "视频连接状态正常",
+            "tone": "warning",
+        },
+        {
+            "key": "events",
+            "label": "识别事件",
+            "value": event_open_count,
+            "unit": "条",
+            "hint": "未闭环智能分析事件",
+            "tone": "ai",
+        },
+    ]
+    return {
+        "mine_id": mine_id,
+        "channel_total": channel_total,
+        "channel_configured": channel_total,
+        "channel_online": channel_online,
+        "channel_offline": channel_offline,
+        "channel_fault": channel_fault,
+        "event_open_count": event_open_count,
+        "snapshot_count": snapshot_count,
+        "modules": module_configs(db, mine_id),
+        "summary": summary,
+    }
+
+
+def module_summary(db: Session, mine_id: str, module: str) -> dict:
+    _require_module(module)
+    today_start, today_end = utc_day_bounds()
+    open_events = video_repo.count_events(
+        db, mine_id, module=module, statuses=OPEN_EVENT_STATUSES
+    )
+
+    if module == VIDEO_MODULE_DOOR:
+        normal = video_repo.count_channels(db, mine_id, module=module, status="online")
+        offline = video_repo.count_channels(db, mine_id, module=module, status="offline")
+        fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
+        car_today = video_repo.count_records(
+            db,
+            mine_id,
+            module=module,
+            record_type="vehicle_passage",
+            start=today_start,
+            end=today_end,
+        )
+        person_today = video_repo.sum_person_count(
+            db,
+            mine_id,
+            module=module,
+            record_type="person_passage",
+            start=today_start,
+            end=today_end,
+        )
+        metrics = [
+            _metric("normal_door", "正常风门", normal, "扇", "normal"),
+            _metric("offline_device", "离线设备", offline, "台", "offline"),
+            _metric("fault_device", "设备故障", fault, "台", "fault"),
+            _metric("open_warning", "实时预警", open_events, "条", "warning"),
+            _metric("car_today", "今日车辆", car_today, "辆", "accent"),
+            _metric("person_today", "今日人员", person_today, "人", "accent"),
+        ]
+        summary = [
+            _card("normal", "正常风门", normal, "扇", tone="normal"),
+            _card("offline", "离线设备", offline, "台", tone="offline"),
+            _card("fault", "设备故障", fault, "台", tone="fault"),
+            _card("warning", "实时预警", open_events, "条", tone="warning"),
+            _card("car", "今日车辆", car_today, "辆", tone="accent"),
+            _card("person", "今日人员", person_today, "人", tone="accent"),
+        ]
+    elif module == VIDEO_MODULE_LOCAL_FAN:
+        normal = video_repo.count_channels(db, mine_id, module=module, status="online")
+        offline = video_repo.count_channels(db, mine_id, module=module, status="offline")
+        fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
+        onsite_person = video_repo.count_records(
+            db, mine_id, module=module, record_type="person_track", in_progress_only=True
+        )
+        enter_today = video_repo.count_records(
+            db,
+            mine_id,
+            module=module,
+            record_type="person_track",
+            start=today_start,
+            end=today_end,
+        )
+        metrics = [
+            _metric("normal_fan", "风机正常", normal, "台", "normal"),
+            _metric("offline_fan", "风机离线", offline, "台", "offline"),
+            _metric("fault_fan", "风机故障", fault, "台", "fault"),
+            _metric("open_warning", "实时预警", open_events, "条", "warning"),
+            _metric("onsite_person", "实时人数", onsite_person, "人", "accent"),
+            _metric("enter_today", "今日进入", enter_today, "人", "accent"),
+        ]
+        summary = [
+            _card("normal", "风机正常", normal, "台", tone="normal"),
+            _card("offline", "风机离线", offline, "台", tone="offline"),
+            _card("fault", "风机故障", fault, "台", tone="fault"),
+            _card("warning", "实时预警", open_events, "条", tone="warning"),
+            _card("onsite", "实时人数", onsite_person, "人", tone="accent"),
+            _card("enter", "今日进入", enter_today, "人", tone="accent"),
+        ]
+    else:  # VIDEO_MODULE_MAIN_FAN
+        channels = video_repo.list_channels(db, mine_id, module=module)
+        running = sum(
+            1
+            for item in channels
+            if str(item.properties.get("run_state", "")) == "running"
+        )
+        stopped = sum(
+            1
+            for item in channels
+            if str(item.properties.get("run_state", "")) == "stopped"
+        )
+        fault = video_repo.count_channels(db, mine_id, module=module, status="fault")
+        detect_today = video_repo.count_records(
+            db,
+            mine_id,
+            module=module,
+            record_type="person_detect",
+            start=today_start,
+            end=today_end,
+        )
+        records_today = video_repo.count_records(
+            db, mine_id, module=module, start=today_start, end=today_end
+        )
+        metrics = [
+            _metric("running_fan", "运行风机", running, "台", "normal"),
+            _metric("stopped_fan", "停止风机", stopped, "台", "offline"),
+            _metric("fault_device", "设备故障", fault, "台", "fault"),
+            _metric("open_warning", "预警总数", open_events, "条", "warning"),
+            _metric("person_detect", "人员检测", detect_today, "次", "accent"),
+            _metric("records_today", "今日记录", records_today, "条", "accent"),
+        ]
+        summary = [
+            _card("running", "运行风机", running, "台", tone="normal"),
+            _card("stopped", "停止风机", stopped, "台", tone="offline"),
+            _card("fault", "设备故障", fault, "台", tone="fault"),
+            _card("warning", "预警总数", open_events, "条", tone="warning"),
+            _card("detect", "人员检测", detect_today, "次", tone="accent"),
+            _card("records", "今日记录", records_today, "条", tone="accent"),
+        ]
+
+    return {
+        "mine_id": mine_id,
+        "module": module,
+        "title": MODULE_TITLES[module],
+        "status_title": MODULE_DEFINITIONS[module]["status_title"],
+        "metrics": metrics,
+        "summary": summary,
+    }
+
+
+def _metric(key: str, label: str, value: int | float, unit: str, tone: str) -> dict:
+    return {"key": key, "label": label, "value": value, "unit": unit, "tone": tone}
+
+
+def _card(key: str, label: str, value: int | float, unit: str, tone: str) -> dict:
+    return {"key": key, "label": label, "value": value, "unit": unit, "tone": tone}
+
+
+def _require_module(module: str) -> str:
+    if module not in MODULE_DEFINITIONS:
+        raise APIError(400, "invalid_module", f"不支持的视频模块:{module}")
+    return module
+
+
+# ---------------------------------------------------------------------------
+# 查询接口
+# ---------------------------------------------------------------------------
+
+
+def list_channels(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    status: str | None = None,
+    keyword: str | None = None,
+    limit: int = 200,
+) -> list[dict]:
+    channels = video_repo.list_channels(
+        db, mine_id, module=module, status=status, keyword=keyword, limit=limit
+    )
+    return [_channel_payload(channel) for channel in channels]
+
+
+def get_channel(db: Session, channel_id: str) -> dict:
+    channel = video_repo.get_channel(db, channel_id)
+    if channel is None:
+        raise APIError(404, "video_channel_not_found", "视频通道不存在")
+    return _channel_payload(channel)
+
+
+def list_snapshots(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    snapshot_type: str | None = None,
+    event_type: str | None = None,
+    channel_id: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    keyword: str | None = None,
+    page: int = 1,
+    page_size: int = 24,
+) -> tuple[list[dict], int]:
+    items, total = video_repo.list_snapshots(
+        db,
+        mine_id,
+        module=module,
+        snapshot_type=snapshot_type,
+        event_type=event_type,
+        channel_id=channel_id,
+        start=start,
+        end=end,
+        keyword=keyword,
+        page=page,
+        page_size=page_size,
+    )
+    names = _channel_names(db, video_repo.list_channels(db, mine_id))
+    return (
+        [_snapshot_payload(item, channel_name=names.get(item.channel_id, "")) for item in items],
+        total,
+    )
+
+
+def get_snapshot(db: Session, snapshot_id: str) -> dict:
+    snapshot = video_repo.get_snapshot(db, snapshot_id)
+    if snapshot is None:
+        raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
+    channel = video_repo.get_channel(db, snapshot.channel_id)
+    return _snapshot_payload(
+        snapshot, channel_name=channel.name if channel else ""
+    )
+
+
+def list_events(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    level: str | None = None,
+    status: str | None = None,
+    event_type: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    limit: int = 100,
+) -> list[dict]:
+    events = video_repo.list_events(
+        db,
+        mine_id,
+        module=module,
+        level=level,
+        status=status,
+        event_type=event_type,
+        start=start,
+        end=end,
+        limit=limit,
+    )
+    names = _channel_names(db, video_repo.list_channels(db, mine_id))
+    return [
+        _event_payload(event, channel_name=names.get(event.channel_id or "", ""))
+        for event in events
+    ]
+
+
+def update_event(db: Session, event_id: str, payload: VideoEventUpdate) -> dict:
+    event = video_repo.get_event(db, event_id)
+    if event is None:
+        raise APIError(404, "video_event_not_found", "视频分析事件不存在")
+    data = payload.model_dump(exclude_unset=True, exclude_none=True)
+    if "status" in data:
+        event.status = str(data["status"])
+        if event.status in {"handled", "ignored"}:
+            event.handled_at = _now()
+        else:
+            event.handled_at = None
+    if "handled_by" in data:
+        event.handled_by = str(data["handled_by"])
+    if "handle_remark" in data:
+        event.handle_remark = str(data["handle_remark"])
+    db.add(event)
+    db.commit()
+    db.refresh(event)
+    channel = video_repo.get_channel(db, event.channel_id or "")
+    return _event_payload(event, channel_name=channel.name if channel else "")
+
+
+def list_records(
+    db: Session,
+    mine_id: str,
+    *,
+    module: str | None = None,
+    record_type: str | None = None,
+    channel_id: str | None = None,
+    start: datetime | None = None,
+    end: datetime | None = None,
+    keyword: str | None = None,
+    page: int = 1,
+    page_size: int = 50,
+) -> tuple[list[dict], int]:
+    items, total = video_repo.list_records(
+        db,
+        mine_id,
+        module=module,
+        record_type=record_type,
+        channel_id=channel_id,
+        start=start,
+        end=end,
+        keyword=keyword,
+        page=page,
+        page_size=page_size,
+    )
+    return [_record_payload(item) for item in items], total
+
+
+# ---------------------------------------------------------------------------
+# 通道管理
+# ---------------------------------------------------------------------------
+
+
+def create_channel(db: Session, payload: VideoChannelCreate) -> dict:
+    _require_module(payload.module)
+    if video_repo.get_channel_by_code(db, payload.code) is not None:
+        raise APIError(409, "video_channel_code_exists", "视频通道编码已存在")
+    channel = VideoChannel(**payload.model_dump())
+    channel.file_name = Path(payload.file_path).name if payload.file_path else ""
+    exists, size = _file_meta(payload.file_path)
+    channel.file_size = size if exists else 0
+    channel.last_online_at = _now() if channel.status == "online" else None
+    db.add(channel)
+    db.commit()
+    db.refresh(channel)
+    return _channel_payload(channel)
+
+
+def update_channel(
+    db: Session,
+    channel_id: str,
+    payload: VideoChannelUpdate,
+) -> dict:
+    channel = video_repo.get_channel(db, channel_id)
+    if channel is None:
+        raise APIError(404, "video_channel_not_found", "视频通道不存在")
+    data = payload.model_dump(exclude_unset=True)
+    if "module" in data and data["module"] is not None:
+        _require_module(str(data["module"]))
+    for key, value in data.items():
+        if value is not None:
+            setattr(channel, key, value)
+    if "file_path" in data and data["file_path"] is not None:
+        channel.file_name = Path(str(data["file_path"])).name if data["file_path"] else ""
+        exists, size = _file_meta(str(data["file_path"]))
+        channel.file_size = size if exists else 0
+    if "status" in data and data["status"] == "online":
+        channel.last_online_at = _now()
+    db.add(channel)
+    db.commit()
+    db.refresh(channel)
+    return _channel_payload(channel)
+
+
+def delete_channel(db: Session, channel_id: str) -> None:
+    channel = video_repo.get_channel(db, channel_id)
+    if channel is None:
+        raise APIError(404, "video_channel_not_found", "视频通道不存在")
+    # 先断开关联引用,再删除通道及其抓拍/记录,避免事件出现悬空外键。
+    db.execute(
+        update(VideoAnalysisEvent)
+        .where(VideoAnalysisEvent.channel_id == channel_id)
+        .values(channel_id=None)
+    )
+    db.execute(
+        update(VideoAnalysisEvent)
+        .where(
+            VideoAnalysisEvent.snapshot_id.in_(
+                db.query(VideoSnapshot.id).filter(VideoSnapshot.channel_id == channel_id)
+            )
+        )
+        .values(snapshot_id=None)
+    )
+    db.execute(delete(VideoRecord).where(VideoRecord.channel_id == channel_id))
+    db.execute(delete(VideoSnapshot).where(VideoSnapshot.channel_id == channel_id))
+    db.delete(channel)
+    db.commit()
+
+
+# ---------------------------------------------------------------------------
+# 媒体文件
+# ---------------------------------------------------------------------------
+
+
+def resolve_channel_media(channel_id: str, db: Session) -> tuple[Path, str]:
+    channel = video_repo.get_channel(db, channel_id)
+    if channel is None:
+        raise APIError(404, "video_channel_not_found", "视频通道不存在")
+    path_text = channel.file_path or channel.stream_url
+    if not path_text:
+        raise APIError(404, "video_media_missing", "该通道未配置可播放的视频文件")
+    path = Path(path_text)
+    if not path.is_file():
+        raise APIError(
+            404,
+            "video_media_missing",
+            f"视频文件不存在:{path_text}",
+        )
+    return path, guess_content_type(path.name)
+
+
+def resolve_snapshot_media(snapshot_id: str, db: Session) -> tuple[Path, str]:
+    snapshot = video_repo.get_snapshot(db, snapshot_id)
+    if snapshot is None:
+        raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
+    path_text = snapshot.file_path
+    if not path_text:
+        raise APIError(404, "snapshot_media_missing", "该抓拍记录未保存图像文件")
+    path = Path(path_text)
+    if not path.is_file():
+        raise APIError(404, "snapshot_media_missing", f"图像文件不存在:{path_text}")
+    return path, guess_content_type(path.name)
+
+
+def resolve_snapshot_thumbnail(snapshot_id: str, db: Session) -> tuple[Path, str]:
+    snapshot = video_repo.get_snapshot(db, snapshot_id)
+    if snapshot is None:
+        raise APIError(404, "video_snapshot_not_found", "识别抓拍图像不存在")
+    path_text = snapshot.thumbnail_path or snapshot.file_path
+    if not path_text:
+        raise APIError(404, "snapshot_media_missing", "该抓拍记录未保存图像文件")
+    path = Path(path_text)
+    if not path.is_file():
+        return resolve_snapshot_media(snapshot_id, db)
+    return path, guess_content_type(path.name)
+
+
+def resolve_channel_latest_snapshot(channel_id: str, db: Session) -> tuple[Path, str]:
+    snapshot = video_repo.latest_snapshot(db, channel_id)
+    if snapshot is None:
+        raise APIError(404, "video_snapshot_not_found", "该通道暂无识别抓拍图像")
+    return resolve_snapshot_media(snapshot.id, db)

+ 28 - 0
docs/api.md

@@ -57,6 +57,34 @@
 | GET | `/monitoring/facilities` | 通风设施 |
 | GET | `/monitoring/alarms` | 告警 |
 
+## 工业视频平台
+
+对应页面“工业视频平台”:通道管理、实时视频、分屏预览、状态总览、识别抓拍与智能识别事件。
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/video-platform/overview` | 通道总量、在线/离线/故障、未闭环事件和抓拍总量 |
+| GET | `/video-platform/modules` | 风门/局部风机/主风机页签、检索项与记录表头配置 |
+| GET | `/video-platform/modules/{module}/summary` | 模块状态总览(正常、离线、故障、预警与今日统计) |
+| GET | `/video-platform/channels` | 通道列表,支持模块、状态和关键字筛选 |
+| POST | `/video-platform/channels` | 新增通道 |
+| GET | `/video-platform/channels/{id}` | 通道详情 |
+| PATCH | `/video-platform/channels/{id}` | 更新通道 |
+| DELETE | `/video-platform/channels/{id}` | 删除通道及其抓拍与记录 |
+| GET | `/video-platform/channels/{id}/stream` | 视频流,按库中磁盘路径读取,支持 Range 拖动播放 |
+| GET | `/video-platform/channels/{id}/snapshot` | 通道最近识别抓拍图(抓图按钮) |
+| GET | `/video-platform/snapshots` | 识别抓拍检索(时间范围、类型、设备、关键字分页) |
+| GET | `/video-platform/snapshots/{id}` | 抓拍详情 |
+| GET | `/video-platform/snapshots/{id}/image` | 抓拍原图 |
+| GET | `/video-platform/snapshots/{id}/thumbnail` | 抓拍缩略图 |
+| GET | `/video-platform/events` | 视频分析事件与实时告警 |
+| PATCH | `/video-platform/events/{id}` | 更新事件处理状态 |
+| GET | `/video-platform/records` | 通行与操作记录(车辆/人员通行、未关严、人员轨迹、区域统计、设备操作等) |
+
+`module` 取值为 `door`(风门)、`local_fan`(局部风机)、`main_fan`(主风机及配电室)。
+视频与图像不写入数据库,表中保存磁盘绝对路径,媒体根目录由 `VIDEO_MEDIA_ROOT` 指定;
+`/stream` 与 `/image` 按库中路径流式返回文件,支持 Range 断点拖动,文件缺失时返回 404。
+
 ## 知识中心
 
 | 方法 | 路径 | 说明 |

+ 21 - 0
docs/database-design.md

@@ -33,6 +33,22 @@
 
 生产环境可将 `sensor_readings` 拆到 TDengine 等时序库,`sensors` 仍保留在关系库。
 
+### 工业视频平台
+
+| 表 | 用途 |
+|---|---|
+| `video_channels` | 视频通道、设备树、分屏预览、实时流地址和演示视频磁盘路径 |
+| `video_snapshots` | AI 识别抓拍图像(车辆/人员通行、风门未关严、人员检测等)的元数据与磁盘路径 |
+| `video_analysis_events` | 视频分析事件与实时告警及处理状态 |
+| `video_records` | 通行与操作记录统一宽表(车辆/人员通行、风门未关严、人员轨迹、区域统计、设备操作、预警) |
+
+视频与图像文件本身不落库,表字段保存磁盘绝对路径(演示数据来自风门/局扇/主扇识别演示目录,
+根目录由 `VIDEO_MEDIA_ROOT` 配置),接口按路径流式读取。
+`video_records.record_type` 区分记录类型:
+`vehicle_passage`、`person_passage`、`door_unclosed`、`person_track`、`area_stay`、
+`person_warn`、`person_detect`、`device_operation`、`device_warn`。
+模块维度由 `module` 字段区分:`door`、`local_fan`、`main_fan`。
+
 ### 业务资料
 
 | 表 | 用途 |
@@ -105,6 +121,11 @@ mines 1 ── n data_sources
 mines 1 ── n sensors
 sensors 1 ── n sensor_readings
 mines 1 ── n ventilation_facilities
+mines 1 ── n video_channels
+video_channels 1 ── n video_snapshots
+video_channels 1 ── n video_records
+video_channels 1 ── n video_analysis_events
+video_snapshots 1 ── n video_records
 mines 1 ── n business_documents
 business_documents 1 ── n document_extractions
 business_documents 1 ── n knowledge_items

+ 35 - 0
scripts/seed_video_platform.py

@@ -0,0 +1,35 @@
+"""向当前数据库写入工业视频平台模拟演示数据。
+
+用法(在项目根目录执行):
+
+    python scripts/seed_video_platform.py
+
+脚本幂等:先确保矿井主数据存在(已存在则跳过基础演示数据),
+再写入视频通道、识别抓拍、通行与操作记录、分析事件。
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+    sys.path.insert(0, str(PROJECT_ROOT))
+
+from app.db.init_db import init_db  # noqa: E402
+from app.db.seed import seed_demo_data  # noqa: E402
+from app.db.seed_video_platform import seed_video_platform_demo  # noqa: E402
+from app.db.session import SessionLocal  # noqa: E402
+
+
+def main() -> None:
+    init_db()
+    with SessionLocal() as db:
+        seed_demo_data(db)
+        result = seed_video_platform_demo(db)
+    print("工业视频平台演示数据:", result)
+
+
+if __name__ == "__main__":
+    main()

+ 322 - 0
tests/test_video_platform_api.py

@@ -0,0 +1,322 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from app.core.config import settings
+from app.db.init_db import init_db
+from app.db.seed import seed_demo_data
+from app.db.session import SessionLocal
+from app.main import app
+
+
+def _prepare() -> None:
+    init_db()
+    with SessionLocal() as db:
+        seed_demo_data(db)
+
+
+def _mine_id(client: TestClient) -> str:
+    response = client.get("/api/v1/mines")
+    assert response.status_code == 200
+    return response.json()[0]["id"]
+
+
+def test_video_platform_overview_and_modules() -> None:
+    _prepare()
+    with TestClient(app) as client:
+        mine_id = _mine_id(client)
+
+        overview = client.get(
+            "/api/v1/video-platform/overview", params={"mine_id": mine_id}
+        )
+        assert overview.status_code == 200
+        payload = overview.json()
+        assert payload["channel_total"] == 18
+        assert payload["channel_online"] == 15
+        assert payload["channel_offline"] == 1
+        assert payload["channel_fault"] == 2
+        assert payload["snapshot_count"] > 0
+        assert len(payload["summary"]) == 4
+
+        modules = client.get(
+            "/api/v1/video-platform/modules", params={"mine_id": mine_id}
+        )
+        assert modules.status_code == 200
+        configs = {item["key"]: item for item in modules.json()}
+        assert set(configs) == {"door", "local_fan", "main_fan"}
+        assert len(configs["door"]["record_tabs"]) == 3
+        assert configs["door"]["record_tabs"][0]["record_type"] == "vehicle_passage"
+        assert "AI防夹人预警" in configs["door"]["type_filter_options"]
+        assert "全部设备" in configs["local_fan"]["scope_filter_options"]
+        assert len(configs["local_fan"]["scope_filter_options"]) == 6
+
+
+def test_video_platform_channels_keep_disk_paths() -> None:
+    _prepare()
+    with TestClient(app) as client:
+        mine_id = _mine_id(client)
+
+        response = client.get(
+            "/api/v1/video-platform/channels",
+            params={"mine_id": mine_id, "module": "door"},
+        )
+        assert response.status_code == 200
+        channels = response.json()
+        assert len(channels) == 8
+        doo_channel = channels[0]
+        assert Path(doo_channel["file_path"]).is_absolute()
+        assert doo_channel["file_path"].startswith(str(settings.video_media_path))
+        assert doo_channel["file_name"].endswith(".mp4")
+        assert doo_channel["playback_url"].endswith("/stream")
+        assert doo_channel["snapshot_url"].endswith("/snapshot")
+        assert doo_channel["has_video"] is True
+
+        search = client.get(
+            "/api/v1/video-platform/channels",
+            params={"mine_id": mine_id, "keyword": "变电所"},
+        )
+        assert search.status_code == 200
+        assert [item["code"] for item in search.json()] == ["VC-DOOR-04"]
+
+
+def test_video_platform_status_summary_and_records() -> None:
+    _prepare()
+    with TestClient(app) as client:
+        mine_id = _mine_id(client)
+
+        summary = client.get(
+            "/api/v1/video-platform/modules/door/summary",
+            params={"mine_id": mine_id},
+        )
+        assert summary.status_code == 200
+        metrics = {item["key"]: item["value"] for item in summary.json()["metrics"]}
+        assert metrics["normal_door"] == 6
+        assert metrics["car_today"] == 12
+        assert metrics["person_today"] == 11
+        assert metrics["open_warning"] > 0
+
+        fan_summary = client.get(
+            "/api/v1/video-platform/modules/local_fan/summary",
+            params={"mine_id": mine_id},
+        ).json()
+        fan_metrics = {item["key"]: item["value"] for item in fan_summary["metrics"]}
+        assert fan_metrics["normal_fan"] == 4
+        assert fan_metrics["fault_fan"] == 1
+        assert fan_metrics["onsite_person"] == 2
+
+        main_summary = client.get(
+            "/api/v1/video-platform/modules/main_fan/summary",
+            params={"mine_id": mine_id},
+        ).json()
+        main_metrics = {item["key"]: item["value"] for item in main_summary["metrics"]}
+        assert main_metrics["running_fan"] == 1
+        assert main_metrics["stopped_fan"] == 1
+        assert main_metrics["person_detect"] == 6
+
+        records = client.get(
+            "/api/v1/video-platform/records",
+            params={
+                "mine_id": mine_id,
+                "module": "door",
+                "record_type": "vehicle_passage",
+                "page_size": 5,
+            },
+        )
+        assert records.status_code == 200
+        record_page = records.json()
+        assert record_page["total"] == 12
+        assert len(record_page["items"]) == 5
+        assert record_page["items"][0]["plate_no"].startswith("蒙K")
+        assert record_page["items"][0]["snapshot_image_url"].endswith("/image")
+
+        tracks = client.get(
+            "/api/v1/video-platform/records",
+            params={
+                "mine_id": mine_id,
+                "module": "local_fan",
+                "record_type": "person_track",
+            },
+        ).json()
+        assert tracks["total"] == 8
+        area_rows = client.get(
+            "/api/v1/video-platform/records",
+            params={
+                "mine_id": mine_id,
+                "module": "local_fan",
+                "record_type": "area_stay",
+            },
+        ).json()
+        assert area_rows["total"] == 5
+        assert "avg_stay" in area_rows["items"][0]["properties"]
+
+        warn_rows = client.get(
+            "/api/v1/video-platform/records",
+            params={
+                "mine_id": mine_id,
+                "module": "main_fan",
+                "record_type": "device_warn",
+            },
+        ).json()
+        assert warn_rows["total"] == 4
+        assert warn_rows["items"][0]["warn_type"]
+
+
+def test_video_platform_snapshots_and_events() -> None:
+    _prepare()
+    with TestClient(app) as client:
+        mine_id = _mine_id(client)
+
+        snapshots = client.get(
+            "/api/v1/video-platform/snapshots",
+            params={"mine_id": mine_id, "module": "door", "page_size": 100},
+        )
+        assert snapshots.status_code == 200
+        page = snapshots.json()
+        assert page["total"] == 40
+        assert page["items"][0]["image_url"].endswith("/image")
+
+        filtered = client.get(
+            "/api/v1/video-platform/snapshots",
+            params={
+                "mine_id": mine_id,
+                "module": "door",
+                "event_type": "风门未关严",
+                "page_size": 100,
+            },
+        ).json()
+        assert filtered["total"] == 9
+        assert all(item["gap_mm"] for item in filtered["items"])
+
+        keyword = client.get(
+            "/api/v1/video-platform/snapshots",
+            params={"mine_id": mine_id, "keyword": "蒙K3571"},
+        ).json()
+        assert keyword["total"] == 1
+
+        events = client.get(
+            "/api/v1/video-platform/events",
+            params={"mine_id": mine_id, "module": "door"},
+        )
+        assert events.status_code == 200
+        event_items = events.json()
+        assert len(event_items) == 10
+        assert {item["level"] for item in event_items} >= {"danger", "warning", "info"}
+        linked = [item for item in event_items if item["snapshot_image_url"]]
+        assert linked
+
+        open_events = client.get(
+            "/api/v1/video-platform/events",
+            params={"mine_id": mine_id, "module": "main_fan", "status": "pending"},
+        ).json()
+        assert open_events
+        event_id = open_events[0]["id"]
+        updated = client.patch(
+            f"/api/v1/video-platform/events/{event_id}",
+            json={"status": "handled", "handled_by": "通防科值班员", "handle_remark": "现场已复核"},
+        )
+        assert updated.status_code == 200
+        updated_payload = updated.json()
+        assert updated_payload["status"] == "handled"
+        assert updated_payload["handled_at"] is not None
+        assert updated_payload["handle_remark"] == "现场已复核"
+
+
+def test_video_platform_channel_media_stream_and_crud(tmp_path: Path) -> None:
+    _prepare()
+    video_file = tmp_path / "demo.mp4"
+    video_file.write_bytes(b"0123456789")
+    image_file = tmp_path / "demo.jpg"
+    image_file.write_bytes(b"fake-image-bytes")
+
+    with TestClient(app) as client:
+        mine_id = _mine_id(client)
+
+        created = client.post(
+            "/api/v1/video-platform/channels",
+            json={
+                "mine_id": mine_id,
+                "code": "VC-TEST-01",
+                "name": "测试通道-煤仓口",
+                "module": "door",
+                "area_group": "风门区域",
+                "location": "测试地点",
+                "device_type": "door",
+                "status": "online",
+                "file_path": str(video_file),
+                "properties": {"door_side": "前门"},
+            },
+        )
+        assert created.status_code == 201
+        channel = created.json()
+        channel_id = channel["id"]
+        assert channel["file_path"] == str(video_file)
+        assert channel["file_name"] == "demo.mp4"
+        assert channel["file_size"] == 10
+        assert channel["media_available"] is True
+
+        duplicated = client.post(
+            "/api/v1/video-platform/channels",
+            json={
+                "mine_id": mine_id,
+                "code": "VC-TEST-01",
+                "name": "重复编码",
+                "module": "door",
+            },
+        )
+        assert duplicated.status_code == 409
+
+        full = client.get(f"/api/v1/video-platform/channels/{channel_id}/stream")
+        assert full.status_code == 200
+        assert full.headers["accept-ranges"] == "bytes"
+        assert full.content == b"0123456789"
+
+        partial = client.get(
+            f"/api/v1/video-platform/channels/{channel_id}/stream",
+            headers={"Range": "bytes=2-5"},
+        )
+        assert partial.status_code == 206
+        assert partial.headers["content-range"] == "bytes 2-5/10"
+        assert partial.content == b"2345"
+
+        patched = client.patch(
+            f"/api/v1/video-platform/channels/{channel_id}",
+            json={"name": "测试通道-煤仓口(改)", "status": "offline"},
+        )
+        assert patched.status_code == 200
+        assert patched.json()["name"] == "测试通道-煤仓口(改)"
+        assert patched.json()["status"] == "offline"
+
+        snapshot = client.get(
+            "/api/v1/video-platform/snapshots",
+            params={"mine_id": mine_id, "page_size": 200},
+        ).json()["items"][0]
+        image = client.get(f"/api/v1/video-platform/snapshots/{snapshot['id']}/image")
+        assert image.status_code in (200, 404)
+
+        missing = client.post(
+            "/api/v1/video-platform/channels",
+            json={
+                "mine_id": mine_id,
+                "code": "VC-TEST-02",
+                "name": "测试通道-缺失文件",
+                "module": "door",
+                "file_path": str(tmp_path / "not-found.mp4"),
+            },
+        ).json()
+        missing_stream = client.get(
+            f"/api/v1/video-platform/channels/{missing['id']}/stream"
+        )
+        assert missing_stream.status_code == 404
+        assert missing_stream.json()["code"] == "video_media_missing"
+
+        deleted = client.delete(f"/api/v1/video-platform/channels/{channel_id}")
+        assert deleted.status_code == 204
+        assert (
+            client.get(f"/api/v1/video-platform/channels/{channel_id}").status_code
+            == 404
+        )
+        client.delete(f"/api/v1/video-platform/channels/{missing['id']}")
+
+        assert image_file.exists()  # 图像文件保持磁盘原样,仅记录路径