"""大屏存取、发布和管理页接口 登录用户只能操作自己的大屏;别人的大屏与不存在同样返回 404 """ from datetime import timedelta from typing import Annotated, Literal from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from app.common.response import ApiResponse, success_response from app.core.auth import get_current_user from app.core.exceptions import BizException from app.schemas.saved_screen import ( ListStatus, PublishBrief, PublishHistory, PublishHistoryItem, ScreenCopied, ScreenCopyRequest, ScreenCreate, ScreenCreated, ScreenDetail, ScreenList, ScreenListItem, ScreenPublished, ScreenPublishRequest, ScreenRename, ScreenRestored, ScreenSave, ScreenSaved, ScreenSummary, SortField, ThumbnailResult, TrashEmptied, ) from app.schemas.user import CurrentUser from app.services import thumbnails from app.services.screens import ScreenService router = APIRouter(prefix="/screens", tags=["Screen"]) # 深翻页的 skip 会让 MongoDB 空转,管理页一屏 20 条,这个上限足够 MAX_OFFSET = 10000 THUMBNAIL_URL = "/api/public/thumbnails/{}" User = Annotated[CurrentUser, Depends(get_current_user)] def get_screen_service(request: Request) -> ScreenService: """没配 MongoDB 时大屏数据只会存在进程内存里,重启就丢,所以直接 503""" service = getattr(request.app.state, "screen_service", None) if service is None: raise BizException(503, "未配置 MongoDB,大屏存取不可用") return service Service = Annotated[ScreenService, Depends(get_screen_service)] def thumbnail_url(thumbnail_id: str | None) -> str | None: return None if thumbnail_id is None else THUMBNAIL_URL.format(thumbnail_id) def _list_item(summary: ScreenSummary, trash_days: int) -> ScreenListItem: publish = summary.publish return ScreenListItem( screen_id=summary.screen_id, name=summary.name, thumbnail_url=thumbnail_url(summary.thumbnail), canvas=summary.canvas, status="draft" if publish is None else publish.status, has_unpublished_changes=publish is not None and summary.revision > publish.revision, share_id=None if publish is None else publish.share_id, updated_at=summary.updated_at, published_at=None if publish is None else publish.published_at, deleted_at=summary.deleted_at, purge_at=None if summary.deleted_at is None else summary.deleted_at + timedelta(days=trash_days), ) @router.post("") async def create_screen( body: ScreenCreate, user: User, service: Service ) -> ApiResponse[ScreenCreated]: record = await service.create(user.id, body.name, body.page) return success_response( ScreenCreated(screen_id=record.screen_id, revision=record.revision) ) @router.get("") async def list_screens( user: User, service: Service, keyword: Annotated[str | None, Query(max_length=100)] = None, status: ListStatus | None = None, sort: SortField = "updated_at", order: Literal["asc", "desc"] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, offset: Annotated[int, Query(ge=0, le=MAX_OFFSET)] = 0, deleted: bool = False, ) -> ApiResponse[ScreenList]: items, total = await service.list_screens( user.id, keyword=keyword.strip() or None if keyword else None, status=status, sort=sort, descending=order == "desc", limit=limit, offset=offset, deleted=deleted, ) return success_response( ScreenList( items=[_list_item(i, service.trash_days) for i in items], total=total ) ) @router.delete("/trash") async def empty_trash(user: User, service: Service) -> ApiResponse[TrashEmptied]: """清空回收站:彻底删除,不可恢复。要先于 /{screen_id} 注册""" return success_response(TrashEmptied(count=await service.empty_trash(user.id))) @router.get("/{screen_id}") async def get_screen( screen_id: str, user: User, service: Service ) -> ApiResponse[ScreenDetail]: record = await service.get(screen_id, user.id) publish = record.publish return success_response( ScreenDetail( screen_id=record.screen_id, name=record.name, page=record.page, revision=record.revision, updated_at=record.updated_at, publish=None if publish is None else PublishBrief.model_validate(publish.model_dump(exclude={"revision"})), ) ) @router.put("/{screen_id}") async def save_screen( screen_id: str, body: ScreenSave, user: User, service: Service ) -> ApiResponse[ScreenSaved]: saved = await service.save(screen_id, user.id, body.page, body.revision) return success_response( ScreenSaved(revision=saved.revision, updated_at=saved.updated_at) ) @router.patch("/{screen_id}") async def rename_screen( screen_id: str, body: ScreenRename, user: User, service: Service ) -> ApiResponse[dict]: await service.rename(screen_id, user.id, body.name) return success_response() @router.delete("/{screen_id}") async def delete_screen( screen_id: str, user: User, service: Service ) -> ApiResponse[dict]: await service.delete(screen_id, user.id) return success_response() @router.post("/{screen_id}/restore") async def restore_deleted_screen( screen_id: str, user: User, service: Service ) -> ApiResponse[dict]: await service.restore_deleted(screen_id, user.id) return success_response() @router.post("/{screen_id}/publish") async def publish_screen( screen_id: str, body: ScreenPublishRequest, user: User, service: Service ) -> ApiResponse[ScreenPublished]: info = await service.publish(screen_id, user.id, body.revision) return success_response( ScreenPublished( version=info.version, share_id=info.share_id, published_at=info.published_at ) ) @router.post("/{screen_id}/unpublish") async def unpublish_screen( screen_id: str, user: User, service: Service ) -> ApiResponse[dict]: await service.unpublish(screen_id, user.id) return success_response() @router.post("/{screen_id}/copy") async def copy_screen( screen_id: str, user: User, service: Service, body: ScreenCopyRequest | None = None, ) -> ApiResponse[ScreenCopied]: new_id = await service.copy_screen(screen_id, user.id, body.name if body else None) return success_response(ScreenCopied(screen_id=new_id)) @router.put("/{screen_id}/thumbnail") async def upload_thumbnail( screen_id: str, file: Annotated[UploadFile, File()], user: User, service: Service, ) -> ApiResponse[ThumbnailResult]: data = await file.read(thumbnails.MAX_BYTES + 1) thumbnail_id = await service.set_thumbnail(screen_id, user.id, data) return success_response(ThumbnailResult(thumbnail_url=thumbnail_url(thumbnail_id))) @router.get("/{screen_id}/publishes") async def list_publishes( screen_id: str, user: User, service: Service, limit: Annotated[int, Query(ge=1, le=100)] = 20, offset: Annotated[int, Query(ge=0, le=MAX_OFFSET)] = 0, ) -> ApiResponse[PublishHistory]: entries, total = await service.list_publishes(screen_id, user.id, limit, offset) return success_response( PublishHistory( items=[PublishHistoryItem.model_validate(e.model_dump()) for e in entries], total=total, ) ) @router.post("/{screen_id}/publishes/{version}/restore") async def restore_publish( screen_id: str, version: int, user: User, service: Service ) -> ApiResponse[ScreenRestored]: revision = await service.restore_publish(screen_id, user.id, version) return success_response(ScreenRestored(revision=revision))