init: EasyScreen 电子菜单系统
- server: FastAPI 后端(多屏管理、素材上传、节目编排、客户端注册/配置/心跳/崩溃上报、管理台托管) - android: Java 客户端(minSdk 23,全屏图片/视频轮播、远程配置、开机自启、崩溃上报) - web: React + Vite + antd 管理台(屏幕/素材/节目管理) - 屏幕设备 ID 关联机制、gunicorn 生产部署脚本
This commit is contained in:
@@ -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="登录已过期,请重新登录")
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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/dist(npm 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"))
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user