feat:新增大屏存取发布接口
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user