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
+45 -4
View File
@@ -8,22 +8,34 @@ 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.conversation import (
ConversationCreate,
ConversationCreated,
ConversationDetail,
ConversationList,
ConversationRename,
)
from app.schemas.user import CurrentUser
MAX_OFFSET = 10000
router = APIRouter(prefix="/conversations", tags=["Conversation"])
@router.post("")
async def create_conversation(
request: Request, user: CurrentUser = Depends(get_current_user)
request: Request,
user: CurrentUser = Depends(get_current_user),
body: ConversationCreate | None = None,
) -> ApiResponse[ConversationCreated]:
store = request.app.state.conversation_store
screen_id = body.screen_id if body else None
# 会话挂到大屏上,大屏必须是自己的;没配 MongoDB(没有大屏服务)时无从校验,直接放过
screens = getattr(request.app.state, "screen_service", None)
if screen_id is not None and screens is not None:
if not await screens.exists(screen_id, user.id):
raise BizException(404, "大屏不存在或已删除")
return success_response(
ConversationCreated(conversation_id=await store.create(user.id))
ConversationCreated(conversation_id=await store.create(user.id, screen_id))
)
@@ -32,11 +44,15 @@ async def list_conversations(
request: Request,
user: Annotated[CurrentUser, Depends(get_current_user)],
limit: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0,
offset: Annotated[int, Query(ge=0, le=MAX_OFFSET)] = 0,
screen_id: Annotated[str | None, Query(min_length=1)] = None,
) -> ApiResponse[ConversationList]:
store = request.app.state.conversation_store
return success_response(
ConversationList(conversations=await store.list_by_user(user.id, limit, offset))
ConversationList(
conversations=await store.list_by_user(user.id, limit, offset, screen_id),
total=await store.count_by_user(user.id, screen_id),
)
)
@@ -54,3 +70,28 @@ async def get_conversation(
return success_response(
ConversationDetail(conversation_id=conversation_id, turns=turns)
)
@router.patch("/{conversation_id}")
async def rename_conversation(
conversation_id: str,
body: ConversationRename,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> ApiResponse[dict]:
store = request.app.state.conversation_store
if not await store.rename(conversation_id, user.id, body.title):
raise BizException(404, "会话不存在或已过期")
return success_response()
@router.delete("/{conversation_id}")
async def delete_conversation(
conversation_id: str,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> ApiResponse[dict]:
store = request.app.state.conversation_store
if not await store.delete(conversation_id, user.id):
raise BizException(404, "会话不存在或已过期")
return success_response()
+29
View File
@@ -0,0 +1,29 @@
"""编辑态数据源取数:登录用户传 URL,服务端替浏览器请求,responsePath / transform 仍由前端做"""
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.common.response import ApiResponse, success_response
from app.core.auth import get_current_user
from app.schemas.user import CurrentUser
from app.services.datasource_proxy import fetch_json
router = APIRouter(prefix="/datasource", tags=["DataSource"])
class DataSourceFetch(BaseModel):
url: str = Field(min_length=1, max_length=4096)
method: Literal["GET", "POST"] = "GET"
params: dict[str, Any] | None = None
headers: dict[str, Any] | None = None
@router.post("/fetch")
async def fetch_datasource(
body: DataSourceFetch,
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> ApiResponse[dict[str, Any] | list[Any]]:
data = await fetch_json(body.url, body.method, body.params, body.headers)
return success_response(data)
+6 -4
View File
@@ -85,9 +85,13 @@ class UploadSizeLimit:
return None
def _public(record: DataFileRecord) -> dict:
return {**record.model_dump(), "pinned": bool(record.pinned_by)}
def _detail(record: DataFileRecord) -> DataFileDetail:
# 按接口模型重新校验,去掉 owner_id、sha256、列概况等只在后端用的字段
return DataFileDetail.model_validate(record.model_dump())
return DataFileDetail.model_validate(_public(record))
@limits_router.get("/limits")
@@ -130,9 +134,7 @@ async def list_files(
records = await service.list_files(user.id, conversation_id)
return success_response(
DataFileList(
files=[
DataFileInfo.model_validate(record.model_dump()) for record in records
],
files=[DataFileInfo.model_validate(_public(record)) for record in records],
uploads=service.list_uploads(user.id, conversation_id),
)
)
+54
View File
@@ -0,0 +1,54 @@
"""匿名接口,统一放在 /api/public 下,一眼能看出哪些不鉴权"""
from typing import Any
from fastapi import APIRouter
from fastapi.responses import FileResponse
from app.api.screens import Service
from app.common.response import ApiResponse, success_response
from app.core.exceptions import BizException
from app.schemas.saved_screen import PublicScreen
from app.services.screens import public_page
router = APIRouter(prefix="/public", tags=["Public"])
@router.get("/screens/{share_id}")
async def get_public_screen(
share_id: str, service: Service
) -> ApiResponse[PublicScreen]:
"""线上版本:返回发布快照,不是草稿;已下线、已删除、不存在都是 404"""
summary, snapshot = await service.get_live(share_id)
return success_response(
PublicScreen(
name=summary.name,
page=public_page(snapshot.page),
version=snapshot.version,
published_at=snapshot.published_at,
)
)
@router.get("/screens/{share_id}/datasources/{datasource_id}")
async def fetch_public_datasource(
share_id: str, datasource_id: str, service: Service
) -> ApiResponse[dict[str, Any] | list[Any]]:
"""线上取数:地址取自已发布快照里的数据源配置,前端只传 ID"""
return success_response(
await service.fetch_live_datasource(share_id, datasource_id)
)
@router.get("/thumbnails/{thumbnail_id}")
async def get_thumbnail(thumbnail_id: str, service: Service) -> FileResponse:
opened = service.open_thumbnail(thumbnail_id)
if opened is None:
raise BizException(404, "封面不存在")
path, media_type = opened
# 每次上传都换新 ID,内容永远不变,可以放心长期缓存
return FileResponse(
path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
+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))