"""客户端接口:设备注册、拉取配置、心跳上报、崩溃上报。无鉴权,以 device_id 标识设备。""" import os from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from ..database import get_db from ..models import Playlist, PlaylistItem, Screen, ScreenBinding from ..schemas import ( ClientConfigItem, ClientConfigResponse, ClientHeartbeatRequest, ClientRegisterRequest, CrashReportRequest, ) router = APIRouter(prefix="/api/client", tags=["client"]) HEARTBEAT_ONLINE_SECONDS = 90 # 心跳间隔建议 30-60 秒 @router.post("/register") def register(body: ClientRegisterRequest, db: Session = Depends(get_db)): """客户端首次启动时注册设备。device_id 已存在则更新名称(若为占位名)。""" if not body.device_id or len(body.device_id) > 64: raise HTTPException(status_code=400, detail="device_id 非法") screen = db.scalar(select(Screen).where(Screen.device_id == body.device_id)) if screen: if body.name and (screen.name.startswith("pre-") or screen.name == "未命名屏幕"): screen.name = body.name[:100] db.commit() return {"screen_id": screen.id, "registered": True} screen = Screen( name=(body.name or "未命名屏幕")[:100], device_id=body.device_id, status="online", last_seen=datetime.now(), ) db.add(screen) db.commit() db.refresh(screen) return {"screen_id": screen.id, "registered": True} @router.get("/check") def check_screen(device_id: str, db: Session = Depends(get_db)): """校验屏幕(设备 ID)是否已存在,供 App 端配置时提示。只查询,不创建。""" screen = db.scalar(select(Screen.id).where(Screen.device_id == device_id)) return {"exists": screen is not None} @router.get("/config", response_model=ClientConfigResponse) def get_client_config(device_id: str, db: Session = Depends(get_db)): """返回该屏幕当前生效的播放配置。""" screen = db.scalar( select(Screen) .where(Screen.device_id == device_id) .options(selectinload(Screen.bindings).selectinload(ScreenBinding.playlist)) ) if not screen: raise HTTPException(status_code=404, detail="设备未注册") binding = next((b for b in screen.bindings if b.active), None) if not binding or not binding.playlist: return ClientConfigResponse(configured=False, version=0, items=[]) playlist: Playlist = db.get( Playlist, binding.playlist_id, options=[selectinload(Playlist.items).selectinload(PlaylistItem.asset)], ) items = [ ClientConfigItem( asset_id=item.asset_id, type=item.asset.type, url=item.asset.url, name=item.asset.name, duration=item.duration if item.duration is not None else item.asset.duration, ) for item in playlist.items ] version = int(binding.updated_at.timestamp()) if binding.updated_at else int(datetime.now().timestamp()) return ClientConfigResponse( configured=True, playlist_id=playlist.id, playlist_name=playlist.name, version=version, items=items, ) @router.post("/heartbeat") def heartbeat(body: ClientHeartbeatRequest, db: Session = Depends(get_db)): """心跳上报,更新在线状态。未注册设备也返回 ok(客户端先注册再心跳)。""" screen = db.scalar(select(Screen).where(Screen.device_id == body.device_id)) if screen: screen.status = "online" screen.last_seen = datetime.now() db.commit() return {"ok": True} @router.post("/crash") def report_crash(body: CrashReportRequest): """客户端崩溃上报:堆栈写入 logs/crashes/ 目录,便于远程排查显示屏异常。""" crash_dir = os.path.abspath( os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs", "crashes") ) os.makedirs(crash_dir, exist_ok=True) safe_id = "".join(c for c in body.device_id if c.isalnum() or c in "-_")[:40] or "unknown" filename = f"{safe_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" with open(os.path.join(crash_dir, filename), "w", encoding="utf-8") as f: f.write(f"device_id: {body.device_id}\ntime: {datetime.now().isoformat()}\n\n{body.stacktrace}\n") return {"ok": True}