feat:图表交互
This commit is contained in:
+63
-6
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user