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
+44
View File
@@ -0,0 +1,44 @@
"""管理台认证:JWT 签发与校验(内网管理场景的轻量方案)。"""
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from .config import get_admin_config
ALGORITHM = "HS256"
TOKEN_TTL_HOURS = 24
_bearer = HTTPBearer(auto_error=False)
def create_token(username: str) -> str:
cfg = get_admin_config()
payload = {
"sub": username,
"exp": datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, cfg["token_secret"], algorithm=ALGORITHM)
def verify_credentials(username: str, password: str) -> bool:
cfg = get_admin_config()
return username == cfg.get("username") and password == cfg.get("password")
def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> str:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
try:
payload = jwt.decode(
credentials.credentials,
get_admin_config()["token_secret"],
algorithms=[ALGORITHM],
)
return payload["sub"]
except jwt.PyJWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
+42
View File
@@ -0,0 +1,42 @@
"""应用配置:读取 settings.json,支持多数据源切换(不硬编码连接信息)。"""
import json
import os
from functools import lru_cache
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DEFAULT_SETTINGS_PATH = os.path.join(os.path.dirname(BASE_DIR), "settings.json")
@lru_cache(maxsize=1)
def load_settings() -> dict:
path = os.environ.get("EASYSCREEN_SETTINGS", DEFAULT_SETTINGS_PATH)
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def get_active_db() -> dict:
settings = load_settings()
name = settings["active_db"]
return settings["databases"][name]
def get_database_url() -> str:
db = get_active_db()
if db["type"] == "sqlite":
return f"sqlite:///{os.path.join(os.path.dirname(BASE_DIR), db['path'])}"
if db["type"] == "mysql":
password = db.get("password", "")
auth = db["user"] if not password else f"{db['user']}:{password}"
return (
f"mysql+pymysql://{auth}@{db['host']}:{db['port']}/"
f"{db['database_name']}?charset={db.get('charset', 'utf8mb4')}"
)
raise ValueError(f"不支持的数据库类型: {db['type']}")
def get_server_config() -> dict:
return load_settings()["server"]
def get_admin_config() -> dict:
return load_settings()["admin"]
+37
View File
@@ -0,0 +1,37 @@
"""数据库连接与会话管理(SQLAlchemy 2.0)。"""
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from .config import get_database_url
_database_url = get_database_url()
_engine = create_engine(
_database_url,
pool_pre_ping=True,
connect_args={"check_same_thread": False} if _database_url.startswith("sqlite") else {},
echo=False,
)
@event.listens_for(_engine, "connect")
def _enable_sqlite_fk(dbapi_connection, connection_record):
"""SQLite 默认关闭外键约束,需显式开启以支持 CASCADE。"""
if _database_url.startswith("sqlite"):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(bind=_engine, autocommit=False, autoflush=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+62
View File
@@ -0,0 +1,62 @@
"""EasyScreen 后端入口。"""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .config import get_server_config
from .database import Base, _engine
from .routers import admin_assets, admin_auth, admin_playlists, admin_screens, client
app = FastAPI(title="EasyScreen 电子菜单后台", version="1.0.0")
# 开发期允许前端跨域(Vite dev server);生产建议同域部署或收紧来源
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 自动建表
Base.metadata.create_all(bind=_engine)
# 媒体文件访问(客户端播放地址 /media/{file}
_upload_dir = os.path.abspath(
os.path.join(os.path.dirname(os.path.dirname(__file__)), get_server_config().get("upload_dir", "./uploads"))
)
os.makedirs(_upload_dir, exist_ok=True)
app.mount("/media", StaticFiles(directory=_upload_dir), name="media")
app.include_router(admin_auth.router)
app.include_router(admin_screens.router)
app.include_router(admin_assets.router)
app.include_router(admin_playlists.router)
app.include_router(client.router)
@app.get("/api/health")
def health():
return {"status": "ok"}
# ---------------- 管理台静态托管(生产单端口部署) ----------------
# 若存在 web/distnpm run build 产物),则由本服务直接托管管理台。
# 注意:/api 与 /media 路由已在上方注册,此处 catch-all 仅兜底前端路由。
_WEB_DIST = os.path.abspath(
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "web", "dist")
)
@app.get("/{full_path:path}", include_in_schema=False)
def serve_web(full_path: str):
if not os.path.isdir(_WEB_DIST):
return {"message": "EasyScreen API 运行中。管理台未构建:请在 web/ 目录执行 npm run build"}
# 优先返回真实文件(JS/CSS/图片等),其余路径回退到 index.html(SPA 路由)
file_path = os.path.join(_WEB_DIST, full_path)
if full_path and os.path.isfile(file_path):
return FileResponse(file_path)
return FileResponse(os.path.join(_WEB_DIST, "index.html"))
+98
View File
@@ -0,0 +1,98 @@
"""数据模型:屏幕设备、素材、节目、节目项、屏幕绑定。"""
from datetime import datetime
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Screen(Base):
"""显示屏设备。device_id 由客户端首次启动时生成并注册。"""
__tablename__ = "screens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), default="未命名屏幕")
device_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
location: Mapped[str] = mapped_column(String(200), default="")
status: Mapped[str] = mapped_column(String(20), default="offline") # online/offline
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
bindings: Mapped[list["ScreenBinding"]] = relationship(
back_populates="screen", cascade="all, delete-orphan"
)
class Asset(Base):
"""媒体素材:图片或视频。url 为相对于 /media 的路径。"""
__tablename__ = "assets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(200))
type: Mapped[str] = mapped_column(String(20)) # image / video
url: Mapped[str] = mapped_column(String(500))
size: Mapped[int] = mapped_column(Integer, default=0)
duration: Mapped[int] = mapped_column(Integer, default=0) # 图片默认展示秒数;视频为0表示按视频时长
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
items: Mapped[list["PlaylistItem"]] = relationship(back_populates="asset")
class Playlist(Base):
"""节目:一组素材的轮播清单。"""
__tablename__ = "playlists"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100))
description: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
items: Mapped[list["PlaylistItem"]] = relationship(
back_populates="playlist", cascade="all, delete-orphan", order_by="PlaylistItem.sort_order"
)
bindings: Mapped[list["ScreenBinding"]] = relationship(
back_populates="playlist", cascade="all, delete-orphan"
)
class PlaylistItem(Base):
"""节目项:素材 + 排序 + 可选展示时长覆盖。"""
__tablename__ = "playlist_items"
__table_args__ = (UniqueConstraint("playlist_id", "sort_order", name="uq_playlist_sort"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) # 覆盖素材默认时长(秒)
playlist: Mapped[Playlist] = relationship(back_populates="items")
asset: Mapped[Asset] = relationship(back_populates="items")
class ScreenBinding(Base):
"""屏幕与节目的绑定。一个屏幕同时只有一个 active=True 的绑定。"""
__tablename__ = "screen_bindings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
screen_id: Mapped[int] = mapped_column(ForeignKey("screens.id", ondelete="CASCADE"), index=True)
playlist_id: Mapped[int] = mapped_column(ForeignKey("playlists.id", ondelete="CASCADE"), index=True)
active: Mapped[bool] = mapped_column(Boolean, default=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now)
screen: Mapped[Screen] = relationship(back_populates="bindings")
playlist: Mapped[Playlist] = relationship(back_populates="bindings")
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}
+130
View File
@@ -0,0 +1,130 @@
"""Pydantic 请求/响应模型。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class ScreenCreate(BaseModel):
name: str = "未命名屏幕"
location: str = ""
device_id: str | None = None # 可选:预建屏幕时手动指定客户端设备 ID
class ScreenUpdate(BaseModel):
name: str | None = None
location: str | None = None
class ScreenOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
device_id: str
location: str
status: str
last_seen: datetime | None
created_at: datetime
class ScreenDetail(ScreenOut):
"""屏幕详情:附带当前绑定的节目。"""
playlist: "PlaylistOut | None" = None
class AssetOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
type: str
url: str
size: int
duration: int
created_at: datetime
class PlaylistItemIn(BaseModel):
asset_id: int
duration: int | None = None # None 表示使用素材默认时长
class PlaylistCreate(BaseModel):
name: str
description: str = ""
items: list[PlaylistItemIn] = []
class PlaylistUpdate(BaseModel):
name: str | None = None
description: str | None = None
items: list[PlaylistItemIn] | None = None
class PlaylistItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
asset_id: int
sort_order: int
duration: int | None
asset: AssetOut
class PlaylistOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str
created_at: datetime
items: list[PlaylistItemOut] = []
class BindRequest(BaseModel):
playlist_id: int
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
# ---------------- 客户端接口模型 ----------------
class ClientRegisterRequest(BaseModel):
device_id: str
name: str | None = None
class ClientConfigItem(BaseModel):
asset_id: int
type: str # image / video
url: str # 相对路径,客户端拼接服务器 base_url
name: str
duration: int # 秒;视频为 0 表示按视频自身时长
class ClientConfigResponse(BaseModel):
configured: bool # False 表示该屏幕尚未绑定节目
playlist_id: int | None = None
playlist_name: str | None = None
version: int # 绑定更新计数,客户端可缓存判断
items: list[ClientConfigItem] = []
class ClientHeartbeatRequest(BaseModel):
device_id: str
class CrashReportRequest(BaseModel):
device_id: str
stacktrace: str
ScreenDetail.model_rebuild()