2026-04-08 19:32:23 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-04-10 18:16:15 +08:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query
|
2026-04-09 11:54:30 +08:00
|
|
|
|
from starlette.responses import JSONResponse
|
2026-04-08 19:32:23 +08:00
|
|
|
|
|
2026-04-10 18:16:15 +08:00
|
|
|
|
from app.db import normalize_client_id, pop_next_health, pop_next_measure
|
2026-04-13 14:50:44 +08:00
|
|
|
|
from app.services.water_video import get_water_video_public_url
|
2026-04-09 11:54:30 +08:00
|
|
|
|
from app.settings import Settings, get_settings
|
2026-04-08 19:32:23 +08:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/biomass", tags=["biomass"])
|
|
|
|
|
|
|
2026-04-09 11:54:30 +08:00
|
|
|
|
# 是否有新快照被本次 GET 消费(1/0);body 保持与客户端约定字段一致,不写入 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"}
|
|
|
|
|
|
|
2026-04-08 19:32:23 +08:00
|
|
|
|
|
2026-04-10 18:16:15 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-08 19:32:23 +08:00
|
|
|
|
@router.get("/real/camera/")
|
2026-04-10 18:16:15 +08:00
|
|
|
|
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)
|
2026-04-09 11:54:30 +08:00
|
|
|
|
if not has_new:
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"result": [],
|
|
|
|
|
|
"video_left": "",
|
|
|
|
|
|
"video_right": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=_new_headers(False),
|
|
|
|
|
|
)
|
2026-04-08 19:32:23 +08:00
|
|
|
|
if m.error:
|
2026-04-09 11:54:30 +08:00
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 500,
|
|
|
|
|
|
"msg": m.error,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"result": [],
|
|
|
|
|
|
"video_left": "",
|
|
|
|
|
|
"video_right": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=_new_headers(True),
|
|
|
|
|
|
)
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
2026-04-08 19:32:23 +08:00
|
|
|
|
"data": {
|
2026-04-09 11:54:30 +08:00
|
|
|
|
"result": m.result,
|
|
|
|
|
|
"video_left": m.video_left,
|
|
|
|
|
|
"video_right": m.video_right,
|
2026-04-08 19:32:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-04-09 11:54:30 +08:00
|
|
|
|
headers=_new_headers(True),
|
|
|
|
|
|
)
|
2026-04-08 19:32:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/health/result/")
|
2026-04-10 18:16:15 +08:00
|
|
|
|
async def get_health_result(
|
|
|
|
|
|
settings: Settings = Depends(get_settings),
|
|
|
|
|
|
client_id: str = Depends(_resolve_client_id),
|
|
|
|
|
|
):
|
2026-04-13 17:13:02 +08:00
|
|
|
|
"""行为 / 健康结果:每次 GET 投递该客户端下一条未消费的 FishAction 快照(按 client_id 独立游标)。
|
|
|
|
|
|
|
|
|
|
|
|
每个视频切片被视为独立的视频,会分别投递。
|
|
|
|
|
|
"""
|
2026-04-10 18:16:15 +08:00
|
|
|
|
h, has_new, _ = pop_next_health(settings, client_id)
|
2026-04-09 11:54:30 +08:00
|
|
|
|
if not has_new:
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"behavior_result": "",
|
|
|
|
|
|
"health_result": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=_new_headers(False),
|
|
|
|
|
|
)
|
2026-04-08 19:32:23 +08:00
|
|
|
|
if h.error:
|
2026-04-09 11:54:30 +08:00
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 500,
|
|
|
|
|
|
"msg": h.error,
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"behavior_result": "",
|
|
|
|
|
|
"health_result": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
headers=_new_headers(True),
|
|
|
|
|
|
)
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
2026-04-08 19:32:23 +08:00
|
|
|
|
"data": {
|
2026-04-09 11:54:30 +08:00
|
|
|
|
"behavior_result": h.behavior_result,
|
|
|
|
|
|
"health_result": h.health_result,
|
2026-04-08 19:32:23 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-04-09 11:54:30 +08:00
|
|
|
|
headers=_new_headers(True),
|
|
|
|
|
|
)
|
2026-04-13 14:50:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/water/video/")
|
2026-04-13 17:13:02 +08:00
|
|
|
|
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)
|
2026-04-13 14:50:44 +08:00
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"video_path": video_path,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/sonar/video/")
|
|
|
|
|
|
async def get_sonar_video():
|
|
|
|
|
|
"""声纳图像信息:当前返回空 `video_path`;后续将提供 H.264 编码 MP4 的绝对 URL。"""
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"code": 200,
|
|
|
|
|
|
"msg": "成功",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"video_path": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|