modify: 平台接口上报

This commit is contained in:
2026-08-05 14:12:15 +08:00
parent 39b5ddd585
commit ed8550ddc3
3 changed files with 290 additions and 130 deletions
+2
View File
@@ -113,6 +113,7 @@ async def create_workers(session, config, count=1, worker_manager=None):
alarm_fwd_cfg = config.get("alarm_forward", {})
alarm_fwd_enabled = alarm_fwd_cfg.get("enabled", False)
alarm_fwd_url = alarm_fwd_cfg.get("url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receive")
alarm_fwd_video_url = alarm_fwd_cfg.get("video_upload_url", "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload")
alarm_fwd_timeout = alarm_fwd_cfg.get("timeout", 10.0)
# 初始化数据库
@@ -163,6 +164,7 @@ async def create_workers(session, config, count=1, worker_manager=None):
set_global_var("doubao_analysis_enabled", doubao_enabled)
set_global_var("alarm_forward_enabled", alarm_fwd_enabled)
set_global_var("alarm_forward_url", alarm_fwd_url)
set_global_var("alarm_forward_video_url", alarm_fwd_video_url)
set_global_var("alarm_forward_timeout", alarm_fwd_timeout)
logger.info(
+2
View File
@@ -34,6 +34,7 @@ doubao_analysis_enabled: bool = True
# 告警转发配置
alarm_forward_enabled: bool = False
alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive"
alarm_forward_video_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receiveVideoUpload"
alarm_forward_timeout: float = 10.0
# 心跳超时配置(秒)
@@ -64,6 +65,7 @@ __all__ = [
"doubao_analysis_enabled",
"alarm_forward_enabled",
"alarm_forward_url",
"alarm_forward_video_url",
"alarm_forward_timeout",
"set_global_var",
]
+283 -127
View File
@@ -2,10 +2,11 @@
AI Box Sophon Adapter — 自定义 FastAPI 路由
处理 TOML 无法描述的特殊端点:
- POST /aibox/video_upload 视频上传(FORM-DATA)
- POST /aibox/alarm_report 告警上报(JSON,手动解析)
- POST /aibox_sophon_adapter/video_upload 视频上传(FORM-DATA)
- GET /aibox_sophon_adapter/video/{id} 视频下载/播放
- POST /aibox_sophon_adapter/alarm_report 告警上报(JSON,手动解析)
路由前缀: /aibox(网关自动加 /api/v1)
路由前缀: /aibox_sophon_adapter(网关自动加 /api/v1)
"""
import os
import json
@@ -14,6 +15,7 @@ import hashlib
import logging
import aiofiles
import aiofiles.os
import httpx
from fastapi import APIRouter, UploadFile, File, Form, Request, Body, HTTPException
@@ -36,6 +38,268 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/aibox_sophon_adapter", tags=["aibox_sophon_adapter"])
class _FileStream(httpx.AsyncByteStream):
"""
异步文件流 — 用于流式上传视频,避免一次性将整个文件读入内存。
以 1MB 分块异步读取文件,供 httpx multipart 上传使用。
"""
_CHUNK_SIZE = 1024 * 1024
def __init__(self, file_path: str):
self._file_path = file_path
self._file = None
async def __aenter__(self) -> "_FileStream":
self._file = await aiofiles.open(self._file_path, "rb")
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if self._file is not None:
await self._file.close()
async def __aiter__(self):
assert self._file is not None, "必须在 async with 上下文内使用"
while chunk := await self._file.read(self._CHUNK_SIZE):
yield chunk
# ============================================================
# 辅助函数 — 告警处理各步骤拆分为独立函数
# ============================================================
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) 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:
async 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="视频上传接口",
@@ -70,7 +334,7 @@ async def video_upload(
worker_type = "aibox_sophon_adapter"
object_key = f"{worker_type}/{video_id}/{Video.filename}"
dest = os.path.join(upload_root, object_key)
os.makedirs(os.path.dirname(dest), exist_ok=True)
await aiofiles.os.makedirs(os.path.dirname(dest), exist_ok=True)
# 流式写入文件,同时计算 MD5 和总字节数
total_bytes = 0
@@ -81,7 +345,7 @@ async def video_upload(
total_bytes += len(chunk)
md5_hash.update(chunk)
file_size = os.path.getsize(dest)
file_size = await aiofiles.os.path.getsize(dest)
file_md5 = md5_hash.hexdigest()
logger.info(
@@ -127,16 +391,16 @@ async def video_download(
worker_type = "aibox_sophon_adapter"
# 在 uploads 目录下查找匹配的视频文件
video_dir = os.path.join(upload_root, worker_type, video_id)
if not os.path.isdir(video_dir):
if not await aiofiles.os.path.isdir(video_dir):
raise HTTPException(status_code=404, detail=f"视频不存在: {video_id}")
# 取目录下第一个文件作为视频(通常只有一个)
files = os.listdir(video_dir)
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 os.path.isfile(video_path):
if not await aiofiles.os.path.isfile(video_path):
raise HTTPException(status_code=404, detail=f"视频文件不存在: {files[0]}")
return FileResponse(
@@ -162,17 +426,15 @@ async def alarm_report(
盒子检测到违规事件后,先上传视频再上报 JSON 告警报文。
使用手动 Pydantic 验证以便在验证失败时记录完整请求体,便于排查。
业务处理:
1. 手动 Pydantic 验证基础字段类型(验证失败时记录原始 JSON)
2. model_dump() 获取完整 dict(含额外字段)
3. 提取关键索引字段存入 alarm_record 表
4. 完整报文 JSON 字符串存入 alarm_data
处理流程:
1. 手动 Pydantic 验证基础字段类型(失败时记录原始 JSON)
2. 豆包 AI 分析(判定为 False 则忽略告警)
3. 写入本地 alarm_record 表(含完整报文 JSON)
4. 转发外部平台(先上传视频,替换 VideoFile 后再上报)
返回格式:{"Result": {"Code": 0, "Desc": ""}}
仅当 HTTP 200 + Code 非 0 时,盒子认为上报失败。
"""
zenoh_session = request.app.state.zenoh_session
# 手动 Pydantic 验证,失败时记录完整原始请求体
try:
body = AlarmReportRequest.model_validate(payload)
@@ -187,123 +449,17 @@ async def alarm_report(
# 获取完整报文(含 extra 字段,排除 None 值以生成整洁 JSON)
full_data = body.model_dump(exclude_none=True)
# 豆包 AI 分析(可通过配置开关关闭)
from .global_var import doubao_analysis_enabled
if doubao_analysis_enabled:
async with Client(session=zenoh_session) 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":
# 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,忽略告警")
)
except Exception as e:
logger.error(f"Doubao 分析调用失败: {type(e).__name__}: {e}")
else:
logger.info("[AlarmReport] 豆包分析已关闭 (doubao_analysis.enabled=false)")
board_id = full_data.get("BoardId", "")
board_ip = full_data.get("BoardIp", "")
alarm_id = full_data.get("AlarmId") or full_data.get("UniqueId", "")
summary = full_data.get("Summary", "")
task_session = full_data.get("TaskSession", "")
video_file = full_data.get("VideoFile", "")
# 2. 写入本地数据库(按 AlarmId 去重 + 裁剪旧记录)
await _save_alarm_record(full_data)
media = full_data.get("Media", {})
media_name = media.get("MediaName", "") if isinstance(media, dict) else ""
alarm_time = full_data.get("Time", "")
alarm_timestamp = full_data.get("TimeStamp", 0)
alarm_data_str = json.dumps(full_data, ensure_ascii=False)
try:
from .global_var import db
if db is not None:
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
result = await session.execute(stmt)
existing = result.scalar_one_or_none()
if existing is None:
record = AlarmRecord(
board_id=board_id,
board_ip=board_ip,
alarm_id=alarm_id,
summary=summary,
task_session=task_session,
media_name=media_name,
video_file=video_file,
alarm_time=alarm_time,
alarm_timestamp=alarm_timestamp,
alarm_data=alarm_data_str,
)
session.add(record)
await session.commit()
logger.info(
f"[AlarmReport] 新告警: AlarmId={alarm_id}, "
f"BoardId={board_id}, Summary={summary}, "
f"Media={media_name}, VideoFile={video_file}"
)
# 裁剪告警记录,保留最新 N 条
from .global_var import alarm_max_records, upload_root as _upload_root
if alarm_max_records > 0:
from .alarm_trim import trim_alarm_records
await trim_alarm_records(session, _upload_root, alarm_max_records)
else:
logger.warning(
f"[AlarmReport] 重复告警,已忽略: AlarmId={alarm_id}"
)
except Exception as e:
logger.error(f"[AlarmReport] 数据库写入失败: {e}")
# 告警转发到外部平台
from .global_var import alarm_forward_enabled, alarm_forward_url, alarm_forward_timeout
if alarm_forward_enabled and alarm_forward_url:
try:
async with httpx.AsyncClient(timeout=alarm_forward_timeout) as http_client:
forward_resp = await http_client.post(
alarm_forward_url,
json=full_data,
headers={"Content-Type": "application/json"},
)
if forward_resp.is_success:
logger.info(
f"[AlarmReport] 转发成功: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={forward_resp.status_code}"
)
else:
logger.warning(
f"[AlarmReport] 转发失败: AlarmId={alarm_id}, "
f"URL={alarm_forward_url}, Status={forward_resp.status_code}, "
f"Body={forward_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}"
)
# 3. 转发外部平台(先上传视频,再上报)
await _forward_alarm_to_external(full_data)
return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="")