Files
chongming-plugins/aibox_sophon_adapter/routes.py
T
2026-08-20 16:51:00 +08:00

475 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI Box Sophon Adapter — 自定义 FastAPI 路由
处理 TOML 无法描述的特殊端点:
- POST /aibox_sophon_adapter/video_upload 视频上传(FORM-DATA)
- GET /aibox_sophon_adapter/video/{id} 视频下载/播放
- POST /aibox_sophon_adapter/alarm_report 告警上报(JSON,手动解析)
路由前缀: /aibox_sophon_adapter(网关自动加 /api/v1)
"""
import os
import json
import uuid
import hashlib
import logging
import aiofiles
import aiofiles.os
import httpx
from fastapi import APIRouter, UploadFile, File, Form, Request, Body, HTTPException
from fastapi.responses import FileResponse
from pydantic import ValidationError
from chongming_request import Client
from .models.video_models import VideoUploadResponse, VideoUploadResult
from .models.alarm_models import (
AlarmReportRequest,
AlarmReportResponse,
AlarmReportResult,
)
logger = logging.getLogger(__name__)
# 自定义路由器,前缀与 config.toml 中的 router_prefix 保持一致
# 最终路径由网关 register_routes() 加上全局 prefix (/api/v1) 组合而成
router = APIRouter(prefix="/aibox_sophon_adapter", tags=["aibox_sophon_adapter"])
class _FileStream:
"""
文件流 — 用于流式上传视频,避免一次性将整个文件读入内存。
注意: httpx 0.28 的 multipart 上传(FileField)要求文件对象提供同步的
read()/seek() 方法,httpx 内部以 64KB 分块同步读取;AsyncByteStream 只能
作为整个请求体 content= 使用,无法与 multipart 字段共存。因此这里提供
同步文件接口,配合同步上下文管理器 with 使用(本地磁盘小分块读取,
内存占用依然有界)。
"""
def __init__(self, file_path: str):
self._file_path = file_path
self._file = None
def __enter__(self) -> "_FileStream":
self._file = open(self._file_path, "rb")
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self._file is not None:
self._file.close()
self._file = None
def read(self, size: int = -1) -> bytes:
assert self._file is not None, "必须在 with 上下文内使用"
return self._file.read(size)
def seek(self, offset: int, whence: int = 0) -> int:
assert self._file is not None, "必须在 with 上下文内使用"
return self._file.seek(offset, whence)
# ============================================================
# 辅助函数 — 告警处理各步骤拆分为独立函数
# ============================================================
async def _analyze_with_doubao(zenoh_session, full_data: dict) -> bool:
"""
豆包 AI 分析告警图片,判断是否存在违规。
可通过配置 doubao_analysis.enabled 关闭。判定为 False 时返回 False(忽略告警)。
Args:
zenoh_session: Zenoh 会话(用于调用 doubao Worker)
full_data: 完整告警报文
Returns:
True: 继续处理告警;False: 判定为 False,应忽略告警。
"""
from .global_var import doubao_analysis_enabled
if not doubao_analysis_enabled:
logger.info("[AlarmReport] 豆包分析已关闭 (doubao_analysis.enabled=false)")
return True
async with Client(session=zenoh_session, logger=logger) as client:
try:
result = await client.call(
worker_type="doubao",
subject="doubao.analyze",
payload={
"image_base64": full_data.get("ImageData", ""),
"prompt": f"判断是否存在{full_data.get("Summary", "")},直接返回True或False,不要输出其他内容",
},
timeout=30.0,
retries=1,
)
analysis = result.get("result", "")
error = result.get("error", "")
if error:
logger.warning(f"Doubao 分析失败: {error}")
if analysis == "False":
return False
except Exception as e:
logger.error(f"Doubao 分析调用失败: {type(e).__name__}: {e}")
return True
async def _save_alarm_record(alarm: dict) -> None:
"""
将告警写入本地数据库(按 AlarmId 去重,超出上限时裁剪旧记录)。
Args:
alarm: 完整告警报文字典。
"""
from .global_var import db, alarm_max_records, upload_root
if db is None:
return
alarm_id = alarm.get("AlarmId") or alarm.get("UniqueId", "")
media = alarm.get("Media", {})
media_name = media.get("MediaName", "") if isinstance(media, dict) else ""
alarm_data_str = json.dumps(alarm, ensure_ascii=False)
try:
async with db.get_session() as session:
from .models.orm import AlarmRecord
from sqlalchemy import select
stmt = select(AlarmRecord).where(AlarmRecord.alarm_id == alarm_id) # type: ignore
existing = (await session.execute(stmt)).scalar_one_or_none()
if existing is not None:
logger.warning(f"[AlarmReport] 重复告警,已忽略: AlarmId={alarm_id}")
return
record = AlarmRecord(
board_id=alarm.get("BoardId", ""),
board_ip=alarm.get("BoardIp", ""),
alarm_id=alarm_id,
summary=alarm.get("Summary", ""),
task_session=alarm.get("TaskSession", ""),
media_name=media_name,
video_file=alarm.get("VideoFile", ""),
alarm_time=alarm.get("Time", ""),
alarm_timestamp=alarm.get("TimeStamp", 0),
alarm_data=alarm_data_str,
)
session.add(record)
await session.commit()
logger.info(
f"[AlarmReport] 新告警: AlarmId={alarm_id}, "
f"BoardId={record.board_id}, Summary={record.summary}, "
f"Media={media_name}, VideoFile={record.video_file}"
)
# 裁剪告警记录,保留最新 N 条
if alarm_max_records > 0:
from .alarm_trim import trim_alarm_records
await trim_alarm_records(session, upload_root, alarm_max_records)
except Exception as e:
logger.error(f"[AlarmReport] 数据库写入失败: {e}")
async def _upload_video_to_external(
http_client: httpx.AsyncClient,
video_url: str,
board_ip: str,
board_id: str,
task_session: str,
video_file: str,
) -> str:
"""
上传本地告警视频到外部平台,返回外部 VideoId。
本地视频存储于 data/uploads/aibox_sophon_adapter/<video_file>/ 目录。
采用流式上传,避免将整个文件读入内存。失败时返回空字符串。
Args:
http_client: httpx 异步客户端
video_url: 外部视频上传接口地址
board_ip / board_id / task_session: 上传表单字段
video_file: 本地 VideoId(目录名)
Returns:
外部返回的 VideoId;失败返回空字符串。
"""
from .global_var import upload_root
video_dir_path = os.path.join(upload_root, "aibox_sophon_adapter", video_file)
if not await aiofiles.os.path.isdir(video_dir_path):
logger.warning(f"[AlarmReport] 视频目录不存在: {video_dir_path}")
return ""
video_files = await aiofiles.os.listdir(video_dir_path)
if not video_files:
logger.warning(f"[AlarmReport] 视频目录为空: {video_dir_path}")
return ""
video_path = os.path.join(video_dir_path, video_files[0])
try:
# httpx 0.28 的 multipart 以同步 read() 流式读取文件(64KB 分块),
# 因此这里用同步 with 打开文件流
with _FileStream(video_path) as video_stream:
resp = await http_client.post(
video_url,
data={
"BoardIp": board_ip,
"BoardId": board_id,
"TaskSession": task_session,
},
files={"Video": (video_files[0], video_stream, "video/mp4")}, # type: ignore[arg-type]
)
if resp.is_success:
video_id = resp.json().get("VideoId", "")
logger.info(f"[AlarmReport] 视频上传成功: VideoId={video_id}, URL={video_url}")
return video_id
else:
logger.warning(
f"[AlarmReport] 视频上传失败: Status={resp.status_code}, Body={resp.text[:500]}"
)
except Exception as e:
logger.error(f"[AlarmReport] 视频上传异常: Error={type(e).__name__}: {e}")
return ""
async def _forward_alarm_to_external(alarm: dict) -> None:
"""
转发告警到外部平台:先上传视频获取外部 VideoId,替换 VideoFile 后再上报告警。
可通过配置 alarm_forward.enabled 关闭。所有失败仅记录日志,不影响主流程。
Args:
alarm: 完整告警报文字典。
"""
from .global_var import (
alarm_forward_enabled,
alarm_forward_url,
alarm_forward_video_url,
alarm_forward_timeout,
)
if not (alarm_forward_enabled and alarm_forward_url):
return
alarm_id = alarm.get("AlarmId") or alarm.get("UniqueId", "")
board_id = alarm.get("BoardId", "")
board_ip = alarm.get("BoardIp", "")
task_session = alarm.get("TaskSession", "")
video_file = alarm.get("VideoFile", "")
try:
async with httpx.AsyncClient(timeout=alarm_forward_timeout) as http_client:
# 1. 先上传视频到外部接口
forward_video_id = await _upload_video_to_external(
http_client,
alarm_forward_video_url,
board_ip,
board_id,
task_session,
video_file,
)
# 2. 替换 VideoFile 为外部返回的 VideoId,然后上报告警
forward_data = {**alarm}
if forward_video_id:
forward_data["VideoFile"] = forward_video_id
resp = await http_client.post(
alarm_forward_url,
json=forward_data,
headers={"Content-Type": "application/json"},
)
if resp.is_success:
logger.info(
f"[AlarmReport] 转发成功: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={resp.status_code}"
)
else:
logger.warning(
f"[AlarmReport] 转发失败: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={resp.status_code}, "
f"Body={resp.text[:500]}"
)
except httpx.TimeoutException:
logger.error(
f"[AlarmReport] 转发超时: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Timeout={alarm_forward_timeout}s"
)
except Exception as e:
logger.error(
f"[AlarmReport] 转发异常: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Error={type(e).__name__}: {e}"
)
@router.post(
"/video_upload",
summary="视频上传接口",
description="盒子告警时通过 HTTP FORM-DATA 上报视频文件,返回唯一 VideoId",
response_model=VideoUploadResponse,
)
async def video_upload(
request: Request,
BoardIp: str = Form(..., description="盒子网络地址"),
BoardId: str = Form(..., description="盒子唯一标识"),
TaskSession: str = Form(..., description="所属任务标识"),
GBDeviceId: str = Form(default="", description="盒子国标设备号(不存在则为空字符串)"),
GBTaskChnId: str = Form(default="", description="所属任务国标通道号(不存在则为空字符串)"),
Video: UploadFile = File(..., description="视频文件"),
) -> VideoUploadResponse:
"""
视频上传接口
接收盒子告警时录制的视频文件,保存到本地存储,返回唯一 VideoId。
上报告警报文时可携带该 VideoId 关联视频文件。
复用网关 upload 接口的本地存储机制(data/uploads/ 目录)。
"""
# 生成唯一 VideoId
video_id = uuid.uuid4().hex
# 获取设备声明的文件大小(若 Starlette 不支持则记 -1)
declared_size = getattr(Video, 'size', -1) or -1
try:
# 复用网关存储路径约定: data/uploads/<worker_type>/<uuid>/<filename>
upload_root = request.app.state.upload_root
worker_type = "aibox_sophon_adapter"
object_key = f"{worker_type}/{video_id}/{Video.filename}"
dest = os.path.join(upload_root, object_key)
await aiofiles.os.makedirs(os.path.dirname(dest), exist_ok=True)
# 流式写入文件,同时计算 MD5 和总字节数
total_bytes = 0
md5_hash = hashlib.md5()
async with aiofiles.open(dest, "wb") as afp:
while chunk := await Video.read(1024 * 1024):
await afp.write(chunk)
total_bytes += len(chunk)
md5_hash.update(chunk)
file_size = await aiofiles.os.path.getsize(dest)
file_md5 = md5_hash.hexdigest()
logger.info(
f"[VideoUpload] 视频已保存: VideoId={video_id}, "
f"BoardId={BoardId}, BoardIp={BoardIp}, "
f"TaskSession={TaskSession}, File={Video.filename}, "
f"declared={declared_size}, received={total_bytes}, disk={file_size}, "
f"md5={file_md5}, Path={object_key}"
)
return VideoUploadResponse(
Result=VideoUploadResult(Code=0, Desc=""),
VideoId=video_id,
)
except Exception as e:
logger.error(
f"[VideoUpload] 上传失败: BoardId={BoardId}, "
f"BoardIp={BoardIp}, Error={type(e).__name__}: {e}"
)
return VideoUploadResponse(
Result=VideoUploadResult(Code=-1, Desc=str(e)),
VideoId="",
)
@router.get(
"/video/{video_id}",
summary="视频下载/播放",
description="根据 VideoId 下载或流式播放告警关联视频",
)
async def video_download(
request: Request,
video_id: str,
) -> FileResponse:
"""
视频播放端点
根据 video_upload 返回的 VideoId 查找并返回视频文件。
支持浏览器内嵌播放(Content-Disposition: inline)。
"""
upload_root = request.app.state.upload_root
worker_type = "aibox_sophon_adapter"
# 在 uploads 目录下查找匹配的视频文件
video_dir = os.path.join(upload_root, worker_type, video_id)
if not await aiofiles.os.path.isdir(video_dir):
raise HTTPException(status_code=404, detail=f"视频不存在: {video_id}")
# 取目录下第一个文件作为视频(通常只有一个)
files = await aiofiles.os.listdir(video_dir)
if not files:
raise HTTPException(status_code=404, detail=f"视频目录为空: {video_id}")
video_path = os.path.join(video_dir, files[0])
if not await aiofiles.os.path.isfile(video_path):
raise HTTPException(status_code=404, detail=f"视频文件不存在: {files[0]}")
return FileResponse(
video_path,
media_type="video/mp4",
headers={"Content-Disposition": "inline"},
)
@router.post(
"/alarm_report",
summary="告警上报接口",
description="接收盒子告警报文(JSON),所有字段已在 OpenAPI 中声明,未知字段自动保留",
response_model=AlarmReportResponse,
)
async def alarm_report(
request: Request,
payload: dict = Body(..., description="告警报文 JSON 对象"),
) -> AlarmReportResponse:
"""
告警上报接口
盒子检测到违规事件后,先上传视频再上报 JSON 告警报文。
使用手动 Pydantic 验证以便在验证失败时记录完整请求体,便于排查。
处理流程:
1. 手动 Pydantic 验证基础字段类型(失败时记录原始 JSON)
2. 豆包 AI 分析(判定为 False 则忽略告警)
3. 写入本地 alarm_record 表(含完整报文 JSON)
4. 转发外部平台(先上传视频,替换 VideoFile 后再上报)
返回格式:{"Result": {"Code": 0, "Desc": ""}}
仅当 HTTP 200 + Code 非 0 时,盒子认为上报失败。
"""
# 手动 Pydantic 验证,失败时记录完整原始请求体
try:
body = AlarmReportRequest.model_validate(payload)
except ValidationError as e:
logger.error(
f"[AlarmReport] Pydantic 验证失败:\n"
f" 错误: {e.errors()}\n"
f" 请求体: {json.dumps(payload, ensure_ascii=False)[:3000]}"
)
raise HTTPException(status_code=422, detail=e.errors())
# 获取完整报文(含 extra 字段,排除 None 值以生成整洁 JSON)
full_data = body.model_dump(exclude_none=True)
# 1. 豆包 AI 分析(可通过配置开关关闭),判定为 False 时忽略告警
if not await _analyze_with_doubao(request.app.state.zenoh_session, full_data):
return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警")
)
# 2. 写入本地数据库(按 AlarmId 去重 + 裁剪旧记录)
await _save_alarm_record(full_data)
# 3. 转发外部平台(先上传视频,再上报)
await _forward_alarm_to_external(full_data)
return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="")
)