- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
148 lines
5.0 KiB
Python
148 lines
5.0 KiB
Python
"""管理端:屏幕 CRUD、绑定节目。"""
|
||
import uuid
|
||
from datetime import datetime, timedelta
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session, selectinload
|
||
|
||
from ..auth import get_current_user
|
||
from ..database import get_db
|
||
from ..models import Playlist, Screen, ScreenBinding
|
||
from ..schemas import (
|
||
BindRequest,
|
||
PlaylistOut,
|
||
ScreenCreate,
|
||
ScreenDetail,
|
||
ScreenOut,
|
||
ScreenUpdate,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api/screens", tags=["screens"], dependencies=[Depends(get_current_user)])
|
||
|
||
ONLINE_WINDOW = timedelta(seconds=90) # 与客户端心跳间隔保持一致
|
||
|
||
|
||
def _apply_online_status(screen: Screen) -> Screen:
|
||
"""根据最后心跳时间动态刷新在线状态(不落库,仅展示层)。"""
|
||
if screen.last_seen and datetime.now() - screen.last_seen <= ONLINE_WINDOW:
|
||
screen.status = "online"
|
||
else:
|
||
screen.status = "offline"
|
||
return screen
|
||
|
||
|
||
def _get_screen_or_404(db: Session, screen_id: int) -> Screen:
|
||
screen = db.get(Screen, screen_id)
|
||
if not screen:
|
||
raise HTTPException(status_code=404, detail="屏幕不存在")
|
||
return screen
|
||
|
||
|
||
@router.get("", response_model=list[ScreenDetail])
|
||
def list_screens(db: Session = Depends(get_db)):
|
||
screens = db.scalars(
|
||
select(Screen)
|
||
.options(selectinload(Screen.bindings).selectinload(ScreenBinding.playlist))
|
||
.order_by(Screen.id)
|
||
).all()
|
||
result = []
|
||
for s in screens:
|
||
_apply_online_status(s)
|
||
d = ScreenDetail.model_validate(s)
|
||
active = next((b for b in s.bindings if b.active), None)
|
||
if active and active.playlist:
|
||
d.playlist = PlaylistOut.model_validate(active.playlist)
|
||
result.append(d)
|
||
return result
|
||
|
||
|
||
@router.get("/{screen_id}", response_model=ScreenDetail)
|
||
def get_screen(screen_id: int, db: Session = Depends(get_db)):
|
||
screen = db.get(
|
||
Screen,
|
||
screen_id,
|
||
options=[selectinload(Screen.bindings).selectinload(ScreenBinding.playlist).selectinload(Playlist.items)],
|
||
)
|
||
if not screen:
|
||
raise HTTPException(status_code=404, detail="屏幕不存在")
|
||
_apply_online_status(screen)
|
||
detail = ScreenDetail.model_validate(screen)
|
||
active = next((b for b in screen.bindings if b.active), None)
|
||
if active and active.playlist:
|
||
detail.playlist = PlaylistOut.model_validate(active.playlist)
|
||
return detail
|
||
|
||
|
||
@router.post("", response_model=ScreenOut)
|
||
def create_screen(body: ScreenCreate, db: Session = Depends(get_db)):
|
||
"""手动创建屏幕(可选指定 device_id 用于客户端注册匹配)。"""
|
||
device_id = body.device_id
|
||
if not device_id:
|
||
# 未指定设备 ID 时:先写临时 UUID 满足 NOT NULL 约束,flush 拿到 id 后改为 pre-{id}
|
||
screen = Screen(name=body.name, location=body.location, device_id=f"tmp-{uuid.uuid4().hex}")
|
||
db.add(screen)
|
||
db.flush()
|
||
screen.device_id = f"pre-{screen.id}"
|
||
else:
|
||
exists = db.scalars(select(Screen).where(Screen.device_id == device_id)).first()
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail=f"device_id 已存在(屏幕: {exists.name})")
|
||
screen = Screen(name=body.name, location=body.location, device_id=device_id)
|
||
db.add(screen)
|
||
db.commit()
|
||
db.refresh(screen)
|
||
return screen
|
||
|
||
|
||
@router.put("/{screen_id}", response_model=ScreenOut)
|
||
def update_screen(screen_id: int, body: ScreenUpdate, db: Session = Depends(get_db)):
|
||
screen = _get_screen_or_404(db, screen_id)
|
||
if body.name is not None:
|
||
screen.name = body.name
|
||
if body.location is not None:
|
||
screen.location = body.location
|
||
db.commit()
|
||
db.refresh(screen)
|
||
return screen
|
||
|
||
|
||
@router.delete("/{screen_id}")
|
||
def delete_screen(screen_id: int, db: Session = Depends(get_db)):
|
||
screen = _get_screen_or_404(db, screen_id)
|
||
db.delete(screen)
|
||
db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/{screen_id}/bind", response_model=ScreenDetail)
|
||
def bind_playlist(screen_id: int, body: BindRequest, db: Session = Depends(get_db)):
|
||
"""绑定节目到屏幕(置为 active,其它绑定取消 active)。"""
|
||
screen = _get_screen_or_404(db, screen_id)
|
||
playlist = db.get(Playlist, body.playlist_id)
|
||
if not playlist:
|
||
raise HTTPException(status_code=404, detail="节目不存在")
|
||
|
||
for b in screen.bindings:
|
||
b.active = False
|
||
existing = next((b for b in screen.bindings if b.playlist_id == body.playlist_id), None)
|
||
if existing:
|
||
existing.active = True
|
||
else:
|
||
db.add(ScreenBinding(screen_id=screen.id, playlist_id=body.playlist_id, active=True))
|
||
db.commit()
|
||
|
||
detail = get_screen(screen_id, db)
|
||
return detail
|
||
|
||
|
||
@router.delete("/{screen_id}/bind")
|
||
def unbind_playlist(screen_id: int, db: Session = Depends(get_db)):
|
||
"""解除屏幕当前绑定。"""
|
||
screen = _get_screen_or_404(db, screen_id)
|
||
for b in screen.bindings:
|
||
if b.active:
|
||
db.delete(b)
|
||
db.commit()
|
||
return {"ok": True}
|