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
View File
+92
View File
@@ -0,0 +1,92 @@
"""管理端:素材上传与管理。"""
import os
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..config import get_server_config
from ..database import get_db
from ..models import Asset, PlaylistItem
from ..schemas import AssetOut
router = APIRouter(prefix="/api/assets", tags=["assets"], dependencies=[Depends(get_current_user)])
_EXT_MAP = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/x-matroska": ".mkv",
}
@router.get("", response_model=list[AssetOut])
def list_assets(db: Session = Depends(get_db)):
return db.scalars(select(Asset).order_by(Asset.created_at.desc())).all()
@router.post("", response_model=AssetOut)
async def upload_asset(file: UploadFile = File(...), db: Session = Depends(get_db)):
"""上传图片/视频素材。"""
cfg = get_server_config()
content_type = file.content_type or ""
ext = _EXT_MAP.get(content_type)
if not ext:
raise HTTPException(status_code=400, detail=f"不支持的素材类型: {content_type or '未知'}")
max_bytes = cfg.get("max_upload_mb", 500) * 1024 * 1024
# 流式读取并限制大小
data = b""
while chunk := await file.read(1024 * 1024):
data += chunk
if len(data) > max_bytes:
raise HTTPException(status_code=413, detail=f"文件超过大小限制 {cfg['max_upload_mb']}MB")
filename = f"{uuid.uuid4().hex}{ext}"
upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads")))
os.makedirs(upload_dir, exist_ok=True)
dest = os.path.join(upload_dir, filename)
with open(dest, "wb") as f:
f.write(data)
is_video = content_type.startswith("video/")
asset = Asset(
name=file.filename or filename,
type="video" if is_video else "image",
url=f"/media/{filename}",
size=len(data),
duration=0 if is_video else 10, # 图片默认展示 10 秒
)
db.add(asset)
db.commit()
db.refresh(asset)
return asset
@router.delete("/{asset_id}")
def delete_asset(asset_id: int, db: Session = Depends(get_db)):
asset = db.get(Asset, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="素材不存在")
used = db.scalar(select(func.count(PlaylistItem.id)).where(PlaylistItem.asset_id == asset_id))
if used:
raise HTTPException(
status_code=409,
detail=f"该素材正被 {used} 个节目引用,请先从节目中移除再删除",
)
cfg = get_server_config()
upload_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), cfg.get("upload_dir", "./uploads")))
file_path = os.path.join(upload_dir, os.path.basename(asset.url))
if os.path.exists(file_path):
os.remove(file_path)
db.delete(asset)
db.commit()
return {"ok": True}
+14
View File
@@ -0,0 +1,14 @@
"""管理台登录。"""
from fastapi import APIRouter, HTTPException
from ..auth import create_token, verify_credentials
from ..schemas import LoginRequest, LoginResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(body: LoginRequest):
if not verify_credentials(body.username, body.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
return LoginResponse(token=create_token(body.username))
+89
View File
@@ -0,0 +1,89 @@
"""管理端:节目(播放清单)CRUD。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from ..auth import get_current_user
from ..database import get_db
from ..models import Asset, Playlist, PlaylistItem, ScreenBinding
from ..schemas import PlaylistCreate, PlaylistItemIn, PlaylistOut, PlaylistUpdate
router = APIRouter(prefix="/api/playlists", tags=["playlists"], dependencies=[Depends(get_current_user)])
def _get_playlist_or_404(db: Session, playlist_id: int) -> Playlist:
playlist = db.get(Playlist, playlist_id, options=[selectinload(Playlist.items).selectinload(PlaylistItem.asset)])
if not playlist:
raise HTTPException(status_code=404, detail="节目不存在")
return playlist
def _replace_items(db: Session, playlist_id: int, items: list[PlaylistItemIn]):
"""全量替换节目项。"""
asset_ids = {item.asset_id for item in items}
if asset_ids:
found = set(db.scalars(select(Asset.id).where(Asset.id.in_(asset_ids))).all())
missing = asset_ids - found
if missing:
raise HTTPException(status_code=400, detail=f"素材不存在: {sorted(missing)}")
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == playlist_id))
for idx, item in enumerate(items):
db.add(
PlaylistItem(
playlist_id=playlist_id,
asset_id=item.asset_id,
sort_order=idx,
duration=item.duration,
)
)
@router.get("", response_model=list[PlaylistOut])
def list_playlists(db: Session = Depends(get_db)):
return db.scalars(
select(Playlist)
.options(selectinload(Playlist.items).selectinload(PlaylistItem.asset))
.order_by(Playlist.created_at.desc())
).all()
@router.get("/{playlist_id}", response_model=PlaylistOut)
def get_playlist(playlist_id: int, db: Session = Depends(get_db)):
return _get_playlist_or_404(db, playlist_id)
@router.post("", response_model=PlaylistOut)
def create_playlist(body: PlaylistCreate, db: Session = Depends(get_db)):
playlist = Playlist(name=body.name, description=body.description)
db.add(playlist)
db.flush()
_replace_items(db, playlist.id, body.items)
db.commit()
return _get_playlist_or_404(db, playlist.id)
@router.put("/{playlist_id}", response_model=PlaylistOut)
def update_playlist(playlist_id: int, body: PlaylistUpdate, db: Session = Depends(get_db)):
playlist = _get_playlist_or_404(db, playlist_id)
if body.name is not None:
playlist.name = body.name
if body.description is not None:
playlist.description = body.description
if body.items is not None:
_replace_items(db, playlist.id, body.items)
db.commit()
return _get_playlist_or_404(db, playlist_id)
@router.delete("/{playlist_id}")
def delete_playlist(playlist_id: int, db: Session = Depends(get_db)):
playlist = _get_playlist_or_404(db, playlist_id)
has_active_binding = (
db.scalar(select(ScreenBinding.id).where(ScreenBinding.playlist_id == playlist_id, ScreenBinding.active == True)) # noqa: E712
is not None
)
db.execute(delete(ScreenBinding).where(ScreenBinding.playlist_id == playlist_id))
db.delete(playlist)
db.commit()
return {"ok": True, "unbound_screens": has_active_binding}
+147
View File
@@ -0,0 +1,147 @@
"""管理端:屏幕 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}
+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}