feat:新增大屏存取发布接口

This commit is contained in:
2026-10-11 08:27:30 -07:00
parent dadd797391
commit afc07ba695
23 changed files with 2672 additions and 36 deletions
+249
View File
@@ -0,0 +1,249 @@
"""大屏存取、发布和管理页接口
登录用户只能操作自己的大屏;别人的大屏与不存在同样返回 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))