Files
FishServer/fish_api/app/routers/biomass.py
2026-05-14 17:17:10 +08:00

193 lines
6.0 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.
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Header, Query
from starlette.responses import JSONResponse
from app.db import normalize_client_id, pop_next_health, pop_next_measure
from app.services.sonar_video import get_sonar_video_public_url
from app.services.water_video import get_water_video_public_url
from app.settings import Settings, get_settings
router = APIRouter(prefix="/api/v1/biomass", tags=["biomass"])
# 是否有新快照被本次 GET 消费1/0body 保持与客户端约定字段一致,不写入 has_new。
HEADER_BIOMASS_NEW = "X-Fish-Biomass-New"
def _new_headers(has_new: bool) -> Dict[str, str]:
return {HEADER_BIOMASS_NEW: "1" if has_new else "0"}
# GET /real/camera/ 的 data.result[] 仅含id, type, length, weight, date与客户端约定一致
_BIOMASS_CAMERA_RESULT_KEYS = ("id", "type", "length", "weight", "date")
def _biomass_camera_result_rows(result: Any) -> List[Dict[str, Any]]:
if not isinstance(result, list):
return []
out: List[Dict[str, Any]] = []
for it in result:
if not isinstance(it, dict):
continue
row = {k: it[k] for k in _BIOMASS_CAMERA_RESULT_KEYS if k in it}
if row:
out.append(row)
return out
def _resolve_client_id(
x_fish_client_id: Optional[str] = Header(None, alias="X-Fish-Client-Id"),
client_id: Optional[str] = Query(
None,
description="客户端标识;与请求头 X-Fish-Client-Id 二选一(优先头)。未带时共用 default 游标",
),
) -> str:
if x_fish_client_id is not None and str(x_fish_client_id).strip():
return normalize_client_id(x_fish_client_id)
if client_id is not None and str(client_id).strip():
return normalize_client_id(client_id)
return normalize_client_id(None)
@router.get("/real/camera/")
async def get_real_camera(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""双目实时结果:每次 GET 投递该客户端下一条未消费的 FishMeasure 快照(按 client_id 独立游标)。"""
m, has_new, _ = pop_next_measure(settings, client_id)
if not has_new:
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": {
"result": [],
"video_left": "",
"video_right": "",
},
},
headers=_new_headers(False),
)
if m.error:
return JSONResponse(
content={
"code": 500,
"msg": m.error,
"data": {
"result": [],
"video_left": "",
"video_right": "",
},
},
headers=_new_headers(True),
)
payload: dict = {
"result": _biomass_camera_result_rows(m.result),
"video_left": m.video_left,
"video_right": m.video_right,
}
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": payload,
},
headers=_new_headers(True),
)
@router.get("/health/result/")
async def get_health_result(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""行为 / 健康结果:每次 GET 投递该客户端下一条未消费的 FishAction 快照(按 client_id 独立游标)。
每个视频切片被视为独立的视频,会分别投递。
"""
h, has_new, _ = pop_next_health(settings, client_id)
if not has_new:
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": {
"behavior_result": "",
"health_result": "",
},
},
headers=_new_headers(False),
)
if h.error:
return JSONResponse(
content={
"code": 500,
"msg": h.error,
"data": {
"behavior_result": "",
"health_result": "",
},
},
headers=_new_headers(True),
)
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": {
"behavior_result": h.behavior_result,
"health_result": h.health_result,
},
},
headers=_new_headers(True),
)
@router.get("/water/video/")
async def get_water_video(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""水上视频FishAction 输入 mp4 经 H.264 转码后托管在 /media/,返回 `video_path` 绝对 URL。
如果视频较长超过15秒会自动切分为多个10秒的片段。
每个切片被视为独立的视频每次调用返回一个切片的URL按顺序轮流返回
对齐机制:使用 client_id 参数(请求头 X-Fish-Client-Id 或查询参数 client_id
确保与 /health/result/ 端点对齐返回同一切片。
"""
video_path = await get_water_video_public_url(settings, client_id)
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": {
"video_path": video_path,
},
},
)
@router.get("/sonar/video/")
async def get_sonar_video(
settings: Settings = Depends(get_settings),
client_id: str = Depends(_resolve_client_id),
):
"""声呐视频:经 ffmpeg 切片(顺序完整块或末尾 N 秒,见 BIOMASS_SONAR_SLICE_ORDER
托管在 /media/;每次返回最近成功发布的 ``video_path`` URL。"""
video_path = await get_sonar_video_public_url(settings, client_id)
return JSONResponse(
content={
"code": 200,
"msg": "成功",
"data": {
"video_path": video_path,
},
},
)