250 lines
7.7 KiB
Python
250 lines
7.7 KiB
Python
"""大屏存取、发布和管理页接口
|
|
|
|
登录用户只能操作自己的大屏;别人的大屏与不存在同样返回 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))
|