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_cfg = config.get("alarm_forward", {})
alarm_fwd_enabled = alarm_fwd_cfg.get("enabled", False) 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_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) 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("doubao_analysis_enabled", doubao_enabled)
set_global_var("alarm_forward_enabled", alarm_fwd_enabled) set_global_var("alarm_forward_enabled", alarm_fwd_enabled)
set_global_var("alarm_forward_url", alarm_fwd_url) 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) set_global_var("alarm_forward_timeout", alarm_fwd_timeout)
logger.info( logger.info(
+2
View File
@@ -34,6 +34,7 @@ doubao_analysis_enabled: bool = True
# 告警转发配置 # 告警转发配置
alarm_forward_enabled: bool = False alarm_forward_enabled: bool = False
alarm_forward_url: str = "http://dg.datagoo.cn:60014/open-api/event-alarm/receive" 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 alarm_forward_timeout: float = 10.0
# 心跳超时配置(秒) # 心跳超时配置(秒)
@@ -64,6 +65,7 @@ __all__ = [
"doubao_analysis_enabled", "doubao_analysis_enabled",
"alarm_forward_enabled", "alarm_forward_enabled",
"alarm_forward_url", "alarm_forward_url",
"alarm_forward_video_url",
"alarm_forward_timeout", "alarm_forward_timeout",
"set_global_var", "set_global_var",
] ]
+283 -127
View File
@@ -2,10 +2,11 @@
AI Box Sophon Adapter — 自定义 FastAPI 路由 AI Box Sophon Adapter — 自定义 FastAPI 路由
处理 TOML 无法描述的特殊端点: 处理 TOML 无法描述的特殊端点:
- POST /aibox/video_upload 视频上传(FORM-DATA) - POST /aibox_sophon_adapter/video_upload 视频上传(FORM-DATA)
- POST /aibox/alarm_report 告警上报(JSON,手动解析) - GET /aibox_sophon_adapter/video/{id} 视频下载/播放
- POST /aibox_sophon_adapter/alarm_report 告警上报(JSON,手动解析)
路由前缀: /aibox(网关自动加 /api/v1) 路由前缀: /aibox_sophon_adapter(网关自动加 /api/v1)
""" """
import os import os
import json import json
@@ -14,6 +15,7 @@ import hashlib
import logging import logging
import aiofiles import aiofiles
import aiofiles.os
import httpx import httpx
from fastapi import APIRouter, UploadFile, File, Form, Request, Body, HTTPException 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"]) 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( @router.post(
"/video_upload", "/video_upload",
summary="视频上传接口", summary="视频上传接口",
@@ -70,7 +334,7 @@ async def video_upload(
worker_type = "aibox_sophon_adapter" worker_type = "aibox_sophon_adapter"
object_key = f"{worker_type}/{video_id}/{Video.filename}" object_key = f"{worker_type}/{video_id}/{Video.filename}"
dest = os.path.join(upload_root, object_key) 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 和总字节数 # 流式写入文件,同时计算 MD5 和总字节数
total_bytes = 0 total_bytes = 0
@@ -81,7 +345,7 @@ async def video_upload(
total_bytes += len(chunk) total_bytes += len(chunk)
md5_hash.update(chunk) md5_hash.update(chunk)
file_size = os.path.getsize(dest) file_size = await aiofiles.os.path.getsize(dest)
file_md5 = md5_hash.hexdigest() file_md5 = md5_hash.hexdigest()
logger.info( logger.info(
@@ -127,16 +391,16 @@ async def video_download(
worker_type = "aibox_sophon_adapter" worker_type = "aibox_sophon_adapter"
# 在 uploads 目录下查找匹配的视频文件 # 在 uploads 目录下查找匹配的视频文件
video_dir = os.path.join(upload_root, worker_type, video_id) 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}") raise HTTPException(status_code=404, detail=f"视频不存在: {video_id}")
# 取目录下第一个文件作为视频(通常只有一个) # 取目录下第一个文件作为视频(通常只有一个)
files = os.listdir(video_dir) files = await aiofiles.os.listdir(video_dir)
if not files: if not files:
raise HTTPException(status_code=404, detail=f"视频目录为空: {video_id}") raise HTTPException(status_code=404, detail=f"视频目录为空: {video_id}")
video_path = os.path.join(video_dir, files[0]) 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]}") raise HTTPException(status_code=404, detail=f"视频文件不存在: {files[0]}")
return FileResponse( return FileResponse(
@@ -162,17 +426,15 @@ async def alarm_report(
盒子检测到违规事件后,先上传视频再上报 JSON 告警报文。 盒子检测到违规事件后,先上传视频再上报 JSON 告警报文。
使用手动 Pydantic 验证以便在验证失败时记录完整请求体,便于排查。 使用手动 Pydantic 验证以便在验证失败时记录完整请求体,便于排查。
业务处理: 处理流程:
1. 手动 Pydantic 验证基础字段类型(验证失败时记录原始 JSON) 1. 手动 Pydantic 验证基础字段类型(失败时记录原始 JSON)
2. model_dump() 获取完整 dict(含额外字段) 2. 豆包 AI 分析(判定为 False 则忽略告警)
3. 提取关键索引字段存入 alarm_record 表 3. 写入本地 alarm_record 表(含完整报文 JSON)
4. 完整报文 JSON 字符串存入 alarm_data 4. 转发外部平台(先上传视频,替换 VideoFile 后再上报)
返回格式:{"Result": {"Code": 0, "Desc": ""}} 返回格式:{"Result": {"Code": 0, "Desc": ""}}
仅当 HTTP 200 + Code 非 0 时,盒子认为上报失败。 仅当 HTTP 200 + Code 非 0 时,盒子认为上报失败。
""" """
zenoh_session = request.app.state.zenoh_session
# 手动 Pydantic 验证,失败时记录完整原始请求体 # 手动 Pydantic 验证,失败时记录完整原始请求体
try: try:
body = AlarmReportRequest.model_validate(payload) body = AlarmReportRequest.model_validate(payload)
@@ -187,123 +449,17 @@ async def alarm_report(
# 获取完整报文(含 extra 字段,排除 None 值以生成整洁 JSON) # 获取完整报文(含 extra 字段,排除 None 值以生成整洁 JSON)
full_data = body.model_dump(exclude_none=True) full_data = body.model_dump(exclude_none=True)
# 豆包 AI 分析(可通过配置开关关闭) # 1. 豆包 AI 分析(可通过配置开关关闭),判定为 False 时忽略告警
from .global_var import doubao_analysis_enabled if not await _analyze_with_doubao(request.app.state.zenoh_session, full_data):
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":
return AlarmReportResponse( return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="Doubao 分析判定为 False,忽略告警") 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", "") # 2. 写入本地数据库(按 AlarmId 去重 + 裁剪旧记录)
board_ip = full_data.get("BoardIp", "") await _save_alarm_record(full_data)
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", "")
media = full_data.get("Media", {}) # 3. 转发外部平台(先上传视频,再上报)
media_name = media.get("MediaName", "") if isinstance(media, dict) else "" await _forward_alarm_to_external(full_data)
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}"
)
return AlarmReportResponse( return AlarmReportResponse(
Result=AlarmReportResult(Code=0, Desc="") Result=AlarmReportResult(Code=0, Desc="")