"""匿名接口,统一放在 /api/public 下,一眼能看出哪些不鉴权""" from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import FileResponse from app.api.files import MAX_OFFSET, MAX_PAGE_ROWS, _detail, _public, _service from app.api.screens import Service, thumbnail_url from app.common.response import ApiResponse, success_response from app.config import settings from app.core.exceptions import BizException from app.schemas.conversation import ConversationDetail, ConversationList from app.schemas.data_file import ( AggregateRequest, AggregateResult, DataFileDetail, DataFileInfo, DataFileList, DataTableRows, ) from app.schemas.saved_screen import ( DemoScreen, DemoScreenList, PublicScreen, PublishBrief, ) from app.services.screens import LIVE_VARS_MAX_CHARS, public_page router = APIRouter(prefix="/public", tags=["Public"]) # 演示数据的条数上限和浏览器缓存时长(秒) DEMO_LIMIT = 20 DEMO_CACHE_SECONDS = 60 @router.get("/demo/screens") async def list_demo_screens( service: Service, response: Response ) -> ApiResponse[DemoScreenList]: """未登录演示:演示账号(DEMO_OWNER_ID)下的大屏,页面去掉数据源 headers;没配账号时为空""" owner_id = settings.demo_owner_id if owner_id is None: return success_response(DemoScreenList(items=[])) records = await service.list_demo(owner_id, DEMO_LIMIT) response.headers["Cache-Control"] = f"public, max-age={DEMO_CACHE_SECONDS}" return success_response( DemoScreenList( items=[ DemoScreen( screen_id=record.screen_id, name=record.name, page=public_page(record.page), thumbnail_url=thumbnail_url(record.thumbnail), revision=record.revision, updated_at=record.updated_at, publish=None if record.publish is None else PublishBrief.model_validate( record.publish.model_dump(exclude={"revision"}) ), ) for record in records ] ) ) def demo_owner() -> int: """演示账号的用户 ID;没配置时没有演示数据,按不存在处理""" owner_id = settings.demo_owner_id if owner_id is None: raise BizException(404, "没有演示数据") return owner_id DemoOwner = Annotated[int, Depends(demo_owner)] # 以下是演示账号的对话和数据文件,只读:形状与登录后的 /conversations、/files 一致, # 前端未登录时换个前缀就能复用;归属一律按演示账号校验,别人的会话和文件与不存在同样是 404 @router.get("/demo/conversations") async def list_demo_conversations( request: Request, screen_id: Annotated[str, Query(min_length=1)], limit: Annotated[int, Query(ge=1, le=20)] = 1, ) -> ApiResponse[ConversationList]: """演示大屏下的会话,最近更新的在前;没配演示账号时为空""" owner_id = settings.demo_owner_id if owner_id is None: return success_response(ConversationList(conversations=[], total=0)) store = request.app.state.conversation_store return success_response( ConversationList( conversations=await store.list_by_user(owner_id, limit, 0, screen_id), total=await store.count_by_user(owner_id, screen_id), ) ) @router.get("/demo/conversations/{conversation_id}") async def get_demo_conversation( conversation_id: str, request: Request, owner_id: DemoOwner ) -> ApiResponse[ConversationDetail]: store = request.app.state.conversation_store if not await store.exists(conversation_id, owner_id): raise BizException(404, "会话不存在或已过期") turns = await store.load_turns(conversation_id, None) return success_response( ConversationDetail(conversation_id=conversation_id, turns=turns) ) @router.get("/demo/files") async def list_demo_files( request: Request, conversation_id: Annotated[str, Query(min_length=1)], owner_id: DemoOwner, ) -> ApiResponse[DataFileList]: # 不带上传任务:那是演示账号自己正在进行的,访客不该看到 records = await _service(request).list_files(owner_id, conversation_id) return success_response( DataFileList( files=[DataFileInfo.model_validate(_public(record)) for record in records] ) ) @router.get("/demo/files/{file_id}") async def get_demo_file( file_id: str, request: Request, owner_id: DemoOwner ) -> ApiResponse[DataFileDetail]: return success_response(_detail(await _service(request).get_file(owner_id, file_id))) @router.get("/demo/files/{file_id}/tables/{table}/rows") async def get_demo_table_rows( file_id: str, table: str, request: Request, owner_id: DemoOwner, offset: Annotated[int, Query(ge=0, le=MAX_OFFSET)] = 0, limit: Annotated[int, Query(ge=1, le=MAX_PAGE_ROWS)] = 100, sort_by: Annotated[str | None, Query(min_length=1)] = None, order: Annotated[Literal["asc", "desc"], Query()] = "asc", ) -> ApiResponse[DataTableRows]: return success_response( await _service(request).get_rows( owner_id, file_id, table, offset, limit, sort_by, order == "desc" ) ) @router.post("/demo/files/{file_id}/tables/{table}/aggregate") async def aggregate_demo_table( file_id: str, table: str, body: AggregateRequest, request: Request, owner_id: DemoOwner, ) -> ApiResponse[AggregateResult]: """POST 但只读:汇总结果算好就返回,不改任何数据""" return success_response( await _service(request).aggregate(owner_id, file_id, table, body) ) @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, variables: Annotated[ str | None, Query(alias="vars", max_length=LIVE_VARS_MAX_CHARS) ] = None, ) -> ApiResponse[dict[str, Any] | list[Any]]: """线上取数:地址取自已发布快照里的数据源配置,前端只传 ID vars 是 JSON 对象 {变量ID: 值},只传该数据源 params 里引用到的变量;服务端只代入 params """ return success_response( await service.fetch_live_datasource(share_id, datasource_id, variables) ) @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"}, )