Эх сурвалжийг харах

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。
康宇 3 долоо хоног өмнө
parent
commit
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
 DEFAULT_MINE_CODE=shangwan
 SEED_DEMO_DATA=true
 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
 ```text
 API 层
 API 层
-  dashboard / documents / monitoring / knowledge
+  dashboard / documents / monitoring / video-platform / knowledge
   diagnosis / evaluations / work-orders / ai
   diagnosis / evaluations / work-orders / ai
 
 
 服务层
 服务层
@@ -105,6 +105,23 @@ DEEPAGENT_MODEL=deepseek-flash
 
 
 初始迁移 `0001_initial` 创建当前全部表。后续结构变化应生成显式增量迁移。
 初始迁移 `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
 ```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,
     mines,
     monitoring,
     monitoring,
     system,
     system,
+    video_platform,
     work_orders,
     work_orders,
 )
 )
 
 
@@ -20,6 +21,7 @@ api_router.include_router(dashboard.router)
 api_router.include_router(mines.router)
 api_router.include_router(mines.router)
 api_router.include_router(documents.router)
 api_router.include_router(documents.router)
 api_router.include_router(monitoring.router)
 api_router.include_router(monitoring.router)
+api_router.include_router(video_platform.router)
 api_router.include_router(knowledge.router)
 api_router.include_router(knowledge.router)
 api_router.include_router(diagnosis.router)
 api_router.include_router(diagnosis.router)
 api_router.include_router(evaluation.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
     storage_capacity_gb: float = 1024.0
     seed_demo_data: bool = False
     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"
     request_trace_header: str = "X-Trace-Id"
 
 
     @property
     @property
@@ -98,6 +102,11 @@ class Settings(BaseSettings):
         path = Path(self.upload_dir)
         path = Path(self.upload_dir)
         return path if path.is_absolute() else PROJECT_ROOT / path
         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
     @property
     def model_configured(self) -> bool:
     def model_configured(self) -> bool:
         return bool(self.openai_api_key)
         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.knowledge import KnowledgeChunk, KnowledgeItem
 from app.db.models.mine import Mine, MineArea, Roadway, WorkFace
 from app.db.models.mine import Mine, MineArea, Roadway, WorkFace
 from app.db.models.monitoring import DataSource, Sensor, SensorReading, VentilationFacility
 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
 from app.db.models.work_order import Alarm, WorkOrder, WorkOrderEvent
 
 
 __all__ = [
 __all__ = [
@@ -41,6 +47,10 @@ __all__ = [
     "Sensor",
     "Sensor",
     "SensorReading",
     "SensorReading",
     "VentilationFacility",
     "VentilationFacility",
+    "VideoAnalysisEvent",
+    "VideoChannel",
+    "VideoRecord",
+    "VideoSnapshot",
     "WorkFace",
     "WorkFace",
     "WorkOrder",
     "WorkOrder",
     "WorkOrderEvent",
     "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,
     VentilationFacility,
 )
 )
 from app.db.models.work_order import Alarm, WorkOrder, WorkOrderEvent
 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:
 def seed_demo_data(db: Session) -> None:
     if db.scalar(select(Mine).where(Mine.code == "shangwan")):
     if db.scalar(select(Mine).where(Mine.code == "shangwan")):
+        # 矿井主数据已存在时,只补种工业视频平台演示数据(内部幂等)。
+        seed_video_platform_demo(db)
         return
         return
 
 
     now = datetime.now(timezone.utc)
     now = datetime.now(timezone.utc)
@@ -505,4 +508,6 @@ def seed_demo_data(db: Session) -> None:
         )
         )
     )
     )
     db.commit()
     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/facilities` | 通风设施 |
 | GET | `/monitoring/alarms` | 告警 |
 | 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` 仍保留在关系库。
 生产环境可将 `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
 mines 1 ── n sensors
 sensors 1 ── n sensor_readings
 sensors 1 ── n sensor_readings
 mines 1 ── n ventilation_facilities
 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
 mines 1 ── n business_documents
 business_documents 1 ── n document_extractions
 business_documents 1 ── n document_extractions
 business_documents 1 ── n knowledge_items
 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()  # 图像文件保持磁盘原样,仅记录路径