Files
my-v0-server/app/api/public.py
T
2026-10-18 05:24:29 -07:00

214 lines
7.4 KiB
Python

"""匿名接口,统一放在 /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"},
)