init: EasyScreen 电子菜单系统

- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管)
- android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报)
- web: React + Vite + antd 管理台(屏幕/素材/节目管理)
- 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
Tatta
2026-08-12 20:57:55 +08:00
commit 4e2775c8c3
58 changed files with 6451 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
"""客户端接口:设备注册、拉取配置、心跳上报、崩溃上报。无鉴权,以 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}