feat:图表交互

This commit is contained in:
2026-10-15 03:38:20 -07:00
parent 565ecf4558
commit 9b64f9fc0b
43 changed files with 1537 additions and 727 deletions
+63 -6
View File
@@ -5,6 +5,8 @@
import asyncio
import copy
import json
import math
import secrets
import weakref
from collections.abc import Callable
@@ -26,8 +28,8 @@ from app.schemas.saved_screen import (
ScreenSummary,
SortField,
)
from app.schemas.screen import Screen
from app.services.datasource_proxy import FetchCache, fetch_json
from app.schemas.screen import Screen, is_scalar
from app.services.datasource_proxy import FetchCache, fetch_json, fill_params
from app.services.screen_store import ScreenStore
from app.services.thumbnails import ThumbnailStorage
@@ -41,6 +43,10 @@ EMPTY_PAGE = {
"dataSource": [],
}
# 线上取数的 vars:整段 JSON 的长度上限(接口层检查)、每个字符串值的长度上限
LIVE_VARS_MAX_CHARS = 2000
LIVE_VAR_VALUE_MAX_CHARS = 200
# 回收站过期清理:一批最多清多少个,两次扫描的间隔(秒)
PURGE_BATCH = 100
TRASH_SWEEP_INTERVAL = 3600
@@ -89,7 +95,10 @@ def _not_found() -> BizException:
def validate_page(page: dict[str, Any]) -> dict[str, Any]:
"""按 Screen 校验结构,但返回的仍是原始 JSON:model_dump 会给没传的字段补出 null,前端读回来就变了"""
"""按 Screen 校验结构,但返回的仍是原始 JSON:model_dump 会给没传的字段补出 null,前端读回来就变了
只查结构,不查变量引用对不对得上:编辑器里常有配到一半的交互,不能因此存不了;引用检查只在 agent 的批末做
"""
try:
Screen.model_validate(page)
except ValidationError as error:
@@ -117,6 +126,41 @@ def _check_script_length(page: dict[str, Any]) -> None:
)
def live_variables(page: dict[str, Any], raw: str | None) -> dict[str, Any]:
"""线上取数用的变量值:快照里声明的变量取默认值,再用浏览器传来的 vars 覆盖
只接受快照里声明过的变量(其余忽略)、标量值、字符串限长;格式不对时 422
"""
canvas = page.get("canvas")
declared = canvas.get("variables") if isinstance(canvas, dict) else None
values = {
item["id"]: item.get("default")
for item in declared or []
if isinstance(item, dict) and isinstance(item.get("id"), str)
}
if raw is None:
return values
try:
given = json.loads(raw)
except (ValueError, RecursionError): # 2000 字符内能嵌套近千层,超出递归深度,按非法输入处理
raise BizException(422, "vars 不是合法的 JSON") from None
if not isinstance(given, dict):
raise BizException(422, "vars 必须是对象")
for key, value in given.items():
if key not in values:
continue
if not is_scalar(value):
raise BizException(422, f"变量 {key} 的值必须是字符串、数字、布尔或 null")
if isinstance(value, float) and not math.isfinite(value):
raise BizException(422, f"变量 {key} 的值必须是有限的数字")
if isinstance(value, str) and len(value) > LIVE_VAR_VALUE_MAX_CHARS:
raise BizException(
422, f"变量 {key} 的值不能超过 {LIVE_VAR_VALUE_MAX_CHARS} 个字符"
)
values[key] = value
return values
def public_page(page: dict[str, Any]) -> dict[str, Any]:
"""线上页面里去掉数据源的 headers:它们常放 API key,线上取数由服务端按快照里的完整配置发起,浏览器用不着"""
sources = page.get("dataSource")
@@ -536,8 +580,13 @@ class ScreenService:
async def get_live(self, share_id: str) -> tuple[ScreenSummary, PublishSnapshot]:
return await self._live(share_id)
async def fetch_live_datasource(self, share_id: str, datasource_id: str):
"""前端只传数据源 ID,请求地址取自已发布快照;不能做成"前端传 URL 后端转发",否则是匿名开放代理"""
async def fetch_live_datasource(
self, share_id: str, datasource_id: str, raw_vars: str | None = None
):
"""前端只传数据源 ID,请求地址取自已发布快照;不能做成"前端传 URL 后端转发",否则是匿名开放代理
raw_vars 是浏览器当前的变量值(JSON 对象),只代入 params 的值;缓存 key 用代入后的 params
"""
_, snapshot = await self._live(share_id)
source = next(
(
@@ -550,10 +599,18 @@ class ScreenService:
if source is None or source.get("type") != "api" or not source.get("url"):
raise BizException(404, "数据源不存在")
method = "POST" if source.get("method") == "POST" else "GET"
variables = live_variables(snapshot.page, raw_vars)
params = source.get("params")
if isinstance(params, dict):
params = fill_params(params, variables)
headers = source.get("headers")
return await self._datasource_cache.get(
(share_id, snapshot.version, datasource_id),
(
share_id,
snapshot.version,
datasource_id,
json.dumps(params, sort_keys=True, ensure_ascii=False),
),
lambda: fetch_json(
str(source["url"]),
method,