feat:图表交互
This commit is contained in:
@@ -20,6 +20,7 @@ from urllib.parse import urljoin
|
||||
import httpx
|
||||
|
||||
from app.core.exceptions import BizException
|
||||
from app.schemas.screen import fill_template
|
||||
|
||||
TIMEOUT = 5.0
|
||||
MAX_BYTES = 2 * 1024 * 1024
|
||||
@@ -113,6 +114,12 @@ def _clean_headers(headers: dict[str, Any] | None) -> dict[str, str]:
|
||||
return cleaned
|
||||
|
||||
|
||||
def fill_params(params: dict[str, Any], variables: dict[str, Any]) -> dict[str, Any]:
|
||||
"""数据源 params 的值里的 {{变量ID}} 换成变量的值,规则同前端 fillTemplate;只代入 params,
|
||||
地址和请求头不代入:线上取数是匿名接口,代入地址就成了开放代理"""
|
||||
return {key: fill_template(value, variables) for key, value in params.items()}
|
||||
|
||||
|
||||
def _query_value(value: Any) -> Any:
|
||||
return value if isinstance(value, (str, int, float, bool)) else json.dumps(value)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""物料目录的 MongoDB 存储:每个组件一条记录,目录版本放在元数据记录中,
|
||||
"""物料目录的 MongoDB 存储:每个组件一条记录,目录版本和共享事件组放在元数据记录中,
|
||||
多个物料共用的字段分组单独存放,每个分组一条记录"""
|
||||
|
||||
from loguru import logger
|
||||
@@ -22,6 +22,14 @@ class MongoMaterialRepository:
|
||||
meta = await self._materials.find_one({"_id": META_ID})
|
||||
if meta is None:
|
||||
return None
|
||||
if "event_groups" not in meta:
|
||||
# 共享事件组之前的导入:事件写在各物料上,按现在的格式读出来每个物料都没有事件。
|
||||
# 不兼容旧格式,按未导入处理,重新导入即可
|
||||
logger.error(
|
||||
"物料目录是旧格式导入的(元数据记录没有 event_groups),按未导入处理;"
|
||||
"运行 uv run python -m scripts.seed_materials 重新导入后重启"
|
||||
)
|
||||
return None
|
||||
documents = await self._materials.find({"_id": {"$ne": META_ID}}).to_list()
|
||||
groups = await self._field_groups.find({}).to_list()
|
||||
return ComponentCatalog.model_validate(
|
||||
@@ -29,6 +37,7 @@ class MongoMaterialRepository:
|
||||
"version": meta["version"],
|
||||
"components": _ordered(documents),
|
||||
"field_groups": _ordered(groups),
|
||||
"event_groups": meta["event_groups"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -41,7 +50,16 @@ class MongoMaterialRepository:
|
||||
await collection.delete_many({})
|
||||
if items:
|
||||
await collection.insert_many(_documents(items))
|
||||
await self._materials.insert_one({"_id": META_ID, "version": catalog.version})
|
||||
# 事件组只有几项,跟版本一起放在元数据记录里
|
||||
await self._materials.insert_one(
|
||||
{
|
||||
"_id": META_ID,
|
||||
"version": catalog.version,
|
||||
"event_groups": [
|
||||
group.model_dump(mode="json") for group in catalog.event_groups
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _documents(items) -> list[dict]:
|
||||
@@ -65,7 +83,7 @@ async def warm_material_cache(db: AsyncDatabase | None) -> ComponentCatalog | No
|
||||
if catalog is None:
|
||||
logger.error(
|
||||
"物料目录未载入:{},新增、更换组件等需要物料的修改将无法执行",
|
||||
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入",
|
||||
"未配置 MONGODB_URI" if db is None else "materials 集合尚未导入或格式过旧",
|
||||
)
|
||||
else:
|
||||
logger.info("已从 MongoDB 加载物料 {} 个", len(catalog.components))
|
||||
|
||||
+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