Files
my-v0-server/app/services/screens.py
T
2026-10-18 02:51:44 -07:00

636 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""大屏的业务逻辑:存取、乐观锁、发布、历史、复制、封面、线上读取和数据源取数
存储层只提供"匹配并更新"这类原语,404 / 409 的判断都在这里
"""
import asyncio
import copy
import json
import math
import secrets
import weakref
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from uuid import uuid4
from loguru import logger
from pydantic import ValidationError
from app.core.exceptions import BizException
from app.schemas.saved_screen import (
NAME_MAX_LENGTH,
ListStatus,
PublishEntry,
PublishInfo,
PublishSnapshot,
ScreenRecord,
ScreenSummary,
SortField,
)
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
# 事件代码和数据源转换函数是字符串形式的 JS,线上会在浏览器里执行;写入权限只给 owner,再限制长度
SCRIPT_MAX_CHARS = 64 * 1024
COPY_SUFFIX = " 副本"
EMPTY_PAGE = {
# 不设背景色:用主题的背景色,组件颜色也跟着主题走
"canvas": {"width": 1920, "height": 1080, "theme": "light"},
"nodes": [],
"dataSource": [],
}
# 线上取数的 vars:整段 JSON 的长度上限(接口层检查)、每个字符串值的长度上限
LIVE_VARS_MAX_CHARS = 2000
LIVE_VAR_VALUE_MAX_CHARS = 200
# 回收站过期清理:一批最多清多少个,两次扫描的间隔(秒)
PURGE_BATCH = 100
TRASH_SWEEP_INTERVAL = 3600
STALE = "大屏已在别处修改"
# 下线 / 删除 / 换封面遇到并发修改时的重读次数
UPDATE_ATTEMPTS = 3
def _now() -> datetime:
return datetime.now(UTC)
class FilePinner(Protocol):
"""数据文件服务里和大屏有关的两个方法"""
async def sync_screen_files(
self, user_id: int, screen_id: str, file_ids: set[str]
) -> None: ...
async def release_screen_files(self, screen_id: str) -> None: ...
def referenced_file_ids(page: dict[str, Any]) -> set[str]:
"""页面里图表绑定的数据文件:nodes[].fileQuery.file_id"""
ids = set()
nodes = page.get("nodes")
for node in nodes if isinstance(nodes, list) else []:
query = node.get("fileQuery") if isinstance(node, dict) else None
file_id = query.get("file_id") if isinstance(query, dict) else None
if isinstance(file_id, str) and file_id:
ids.add(file_id)
return ids
def _publish_is(publish: PublishInfo | None) -> dict[str, Any]:
""" "线上指针还是我读到的那个"的更新条件"""
if publish is None:
return {"publish": None}
return {"publish.version": publish.version, "publish.status": publish.status}
def _not_found() -> BizException:
# 别人的大屏与不存在同样返回 404
return BizException(404, "大屏不存在或已删除")
def validate_page(page: dict[str, Any]) -> dict[str, Any]:
"""按 Screen 校验结构,但返回的仍是原始 JSON:model_dump 会给没传的字段补出 null,前端读回来就变了
只查结构,不查变量引用对不对得上:编辑器里常有配到一半的交互,不能因此存不了;引用检查只在 agent 的批末做
"""
try:
Screen.model_validate(page)
except ValidationError as error:
first = error.errors()[0]
where = ".".join(str(part) for part in first["loc"])
raise BizException(422, f"大屏数据不合法:{where}:{first['msg']}") from None
_check_script_length(page)
return page
def _check_script_length(page: dict[str, Any]) -> None:
scripts = [
event.get("code")
for node in page["nodes"]
for event in (node.get("events") or [])
if isinstance(event, dict)
] + [
source.get("transform")
for source in page.get("dataSource", [])
if isinstance(source, dict)
]
if any(isinstance(text, str) and len(text) > SCRIPT_MAX_CHARS for text in scripts):
raise BizException(
422, f"事件代码和数据转换函数不能超过 {SCRIPT_MAX_CHARS // 1024} KB"
)
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")
if not isinstance(sources, list):
return {**page}
return {
**page,
"dataSource": [
{key: value for key, value in item.items() if key != "headers"}
if isinstance(item, dict)
else item
for item in sources
],
}
class ScreenService:
def __init__(
self,
store: ScreenStore,
thumbnails: ThumbnailStorage,
files: FilePinner | None = None,
trash_days: int = 30,
):
self._store = store
self._thumbnails = thumbnails
# 已保存的大屏引用的数据文件不能过期,否则"刷新数据"会 404;没有文件服务时不管
self._files = files
if trash_days < 1:
raise ValueError("trash_days 必须是正整数")
self.trash_days = trash_days
# 按大屏的锁:保存 / 回滚 / 删除 / 还原都是"改库再同步固定的文件"两步,
# 两个请求交错会让固定的文件跟着先开始、后结束的那个请求的旧页面走。单实例部署,进程内锁就够
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
self._datasource_cache = FetchCache()
def _lock(self, screen_id: str) -> asyncio.Lock:
lock = self._locks.get(screen_id)
if lock is None:
lock = self._locks[screen_id] = asyncio.Lock()
return lock
async def _pin_files(self, owner_id: int, screen_id: str, page: dict[str, Any]):
"""让数据文件跟着大屏的引用走。主操作已经成功,这一步失败只记日志,不让保存报错"""
if self._files is None:
return
try:
await self._files.sync_screen_files(
owner_id, screen_id, referenced_file_ids(page)
)
except Exception as error:
logger.opt(exception=error).error(
"同步大屏 {} 引用的数据文件失败", screen_id
)
async def _release_files(self, screen_id: str) -> None:
if self._files is None:
return
try:
await self._files.release_screen_files(screen_id)
except Exception as error:
logger.opt(exception=error).error(
"放开大屏 {} 固定的数据文件失败", screen_id
)
# ---- 草稿 ----
async def create(
self, owner_id: int, name: str, page: dict[str, Any] | None
) -> ScreenRecord:
now = _now()
record = ScreenRecord(
screen_id=f"s_{uuid4().hex}",
owner_id=owner_id,
name=name,
page=validate_page(copy.deepcopy(EMPTY_PAGE) if page is None else page),
revision=1,
created_at=now,
updated_at=now,
)
await self._store.insert(record)
await self._pin_files(owner_id, record.screen_id, record.page)
return record
async def get(self, screen_id: str, owner_id: int) -> ScreenRecord:
record = await self._store.get(screen_id, owner_id)
if record is None:
raise _not_found()
return record
async def save(
self, screen_id: str, owner_id: int, page: dict[str, Any], revision: int
) -> ScreenSummary:
validate_page(page)
async with self._lock(screen_id):
saved = await self._update_with_revision(
screen_id,
owner_id,
{"page": page, "revision": revision + 1, "updated_at": _now()},
revision,
)
await self._pin_files(owner_id, screen_id, page)
return saved
async def _update_with_revision(
self, screen_id: str, owner_id: int, changes: dict[str, Any], revision: int
) -> ScreenSummary:
updated = await self._store.update(
screen_id, owner_id, changes, revision=revision
)
if updated is not None:
return updated
# 没更新成功:大屏不存在是 404,存在但 revision 不一致是 409
if await self._store.get_summary(screen_id, owner_id) is None:
raise _not_found()
raise BizException(409, STALE)
async def rename(self, screen_id: str, owner_id: int, name: str) -> None:
# 改名不算改内容,revision 不动
changes = {"name": name, "updated_at": _now()}
if await self._store.update(screen_id, owner_id, changes) is None:
raise _not_found()
async def _update_from_summary(
self,
screen_id: str,
owner_id: int,
build: Callable[[ScreenSummary], tuple[dict[str, Any], dict[str, Any]] | None],
) -> None:
"""读出当前状态,据此算出修改,再带着"状态没变"的条件写回;并发改了就重读再来
下线、删除要整段改写 publish,不带条件就会把并发发布写进去的新指针盖回旧值
build 返回 (changes, where);返回 None 表示已经是目标状态,什么都不用做
"""
for _ in range(UPDATE_ATTEMPTS):
summary = await self._store.get_summary(screen_id, owner_id)
if summary is None:
raise _not_found()
built = build(summary)
if built is None:
return
changes, where = built
if await self._store.update(screen_id, owner_id, changes, where=where):
return
raise BizException(409, "操作冲突,请重试")
async def delete(self, screen_id: str, owner_id: int) -> None:
def build(summary: ScreenSummary):
changes: dict[str, Any] = {"deleted_at": _now()}
if summary.publish is None:
return changes, {"publish": None}
# 线上版本同时下线,分享链接立即 404
changes["publish"] = summary.publish.model_copy(
update={"status": "offline"}
)
return changes, _publish_is(summary.publish)
async with self._lock(screen_id):
await self._update_from_summary(screen_id, owner_id, build)
# 进了回收站的大屏不再固定文件,还原时再固定回来
await self._release_files(screen_id)
async def restore_deleted(self, screen_id: str, owner_id: int) -> None:
async with self._lock(screen_id):
record = await self._store.get(screen_id, owner_id, deleted=True)
if record is None:
raise _not_found()
changes = {"deleted_at": None}
if (
await self._store.update(screen_id, owner_id, changes, deleted=True)
is None
):
raise _not_found()
await self._pin_files(owner_id, screen_id, record.page)
# ---- 回收站 ----
async def _purge(self, summary: ScreenSummary) -> bool:
"""彻底删除回收站里的一个大屏
先删大屏本身(带"仍在回收站"的条件,清理期间被还原的不会误删),再清理附属数据
"""
if not await self._store.purge(summary.screen_id, summary.owner_id):
return False
await self._store.delete_publishes(summary.screen_id)
self._thumbnails.delete(summary.thumbnail)
await self._release_files(summary.screen_id)
return True
async def empty_trash(self, owner_id: int) -> int:
"""清空自己的回收站,返回彻底删除的个数"""
count = 0
previous: set[str] = set()
while True:
batch, _ = await self._store.search(
owner_id,
keyword=None,
status=None,
sort="updated_at",
descending=True,
limit=PURGE_BATCH,
offset=0,
deleted=True,
)
ids = {summary.screen_id for summary in batch}
purged = [await self._purge(summary) for summary in batch]
count += sum(purged)
# 空批才算清完;整批都没删成(被并发还原了)就再查一次,
# 但同一批连续两次都删不掉说明有东西卡住,别空转
if not batch or (not any(purged) and ids == previous):
return count
previous = ids
async def purge_expired_trash(self) -> int:
"""彻底删除在回收站里超过保留天数的大屏(所有人的)"""
deadline = _now() - timedelta(days=self.trash_days)
count = 0
previous: set[str] = set()
while True:
batch = await self._store.list_expired_trash(deadline, PURGE_BATCH)
ids = {summary.screen_id for summary in batch}
purged = [await self._purge(summary) for summary in batch]
count += sum(purged)
if not batch or (not any(purged) and ids == previous):
return count
previous = ids
async def run_trash_sweeper(self) -> None:
while True:
try:
if removed := await self.purge_expired_trash():
logger.info(
"已彻底删除 {} 个超过 {} 天的回收站大屏",
removed,
self.trash_days,
)
except Exception as error:
logger.opt(exception=error).error("清理回收站失败")
await asyncio.sleep(TRASH_SWEEP_INTERVAL)
# ---- 管理页 ----
async def list_screens(
self,
owner_id: int,
*,
keyword: str | None,
status: ListStatus | None,
sort: SortField,
descending: bool,
limit: int,
offset: int,
deleted: bool,
) -> tuple[list[ScreenSummary], int]:
return await self._store.search(
owner_id,
keyword=keyword,
status=status,
sort=sort,
descending=descending,
limit=limit,
offset=offset,
deleted=deleted,
)
async def list_demo(self, owner_id: int, limit: int) -> list[ScreenRecord]:
"""演示账号下没删除的大屏(最近修改在前),带页面"""
summaries, _ = await self._store.search(
owner_id,
keyword=None,
status=None,
sort="updated_at",
descending=True,
limit=limit,
offset=0,
deleted=False,
)
records = [await self._store.get(item.screen_id, owner_id) for item in summaries]
return [record for record in records if record is not None]
async def copy_screen(self, screen_id: str, owner_id: int, name: str | None) -> str:
"""只复制草稿和封面,不复制发布状态和历史"""
source = await self.get(screen_id, owner_id)
base = source.name[: NAME_MAX_LENGTH - len(COPY_SUFFIX)]
copied = await self.create(
owner_id,
name or f"{base}{COPY_SUFFIX}",
copy.deepcopy(source.page),
)
if source.thumbnail is not None:
thumbnail = self._thumbnails.copy(source.thumbnail)
if thumbnail is not None:
try:
attached = await self._store.update(
copied.screen_id, owner_id, {"thumbnail": thumbnail}
)
except BaseException:
self._thumbnails.delete(thumbnail)
raise
if not attached:
self._thumbnails.delete(thumbnail)
return copied.screen_id
async def set_thumbnail(self, screen_id: str, owner_id: int, data: bytes) -> str:
thumbnail = self._thumbnails.save(data)
replaced: list[str | None] = []
def build(summary: ScreenSummary):
# 封面只是展示用的,不算改内容:不动 revision 和 updated_at
replaced[:] = [summary.thumbnail]
return {"thumbnail": thumbnail}, {"thumbnail": summary.thumbnail}
try:
await self._update_from_summary(screen_id, owner_id, build)
except BaseException:
self._thumbnails.delete(thumbnail)
raise
# 删的是这次真正换掉的那张;并发上传时各自换掉各自读到的,不会留下孤儿文件
self._thumbnails.delete(replaced[0])
return thumbnail
def open_thumbnail(self, thumbnail_id: str):
return self._thumbnails.open(thumbnail_id)
# ---- 发布 ----
async def publish(
self, screen_id: str, owner_id: int, revision: int
) -> PublishInfo:
record = await self.get(screen_id, owner_id)
if record.revision != revision:
raise BizException(409, "大屏有未保存或已在别处修改的内容,请先保存")
previous = record.publish
snapshot = PublishSnapshot(
screen_id=screen_id,
# 版本号取已有快照的最大值加一:写快照后没来得及更新指针留下的孤儿快照也不会撞号
version=await self._store.last_publish_version(screen_id) + 1,
revision=record.revision,
page=record.page,
published_at=_now(),
published_by=owner_id,
)
if not await self._store.insert_publish(snapshot):
raise BizException(409, "发布冲突,请重试")
info = PublishInfo(
status="published",
version=snapshot.version,
revision=snapshot.revision,
# 第一次发布生成 share_id,之后沿用;下线再发布也沿用
share_id=previous.share_id
if previous
else f"p_{secrets.token_urlsafe(16)}",
published_at=snapshot.published_at,
)
# 条件:草稿没被再保存过(revision),线上指针还是读到的那个——并发发布只有一个能成,
# 否则第一次发布时两边各生成一个 share_id,先返回给前端的那个会失效
try:
updated = await self._store.update(
screen_id,
owner_id,
{"publish": info},
revision=revision,
where=_publish_is(previous),
)
except BaseException:
await self._store.delete_publish(screen_id, snapshot.version)
raise
if updated is None:
await self._store.delete_publish(screen_id, snapshot.version)
raise BizException(409, STALE)
return info
async def unpublish(self, screen_id: str, owner_id: int) -> None:
def build(summary: ScreenSummary):
if summary.publish is None or summary.publish.status == "offline":
return None
offline = summary.publish.model_copy(update={"status": "offline"})
return {"publish": offline}, _publish_is(summary.publish)
await self._update_from_summary(screen_id, owner_id, build)
async def exists(self, screen_id: str, owner_id: int) -> bool:
return await self._store.get_summary(screen_id, owner_id) is not None
async def list_publishes(
self, screen_id: str, owner_id: int, limit: int, offset: int
) -> tuple[list[PublishEntry], int]:
if await self._store.get_summary(screen_id, owner_id) is None:
raise _not_found()
return await self._store.list_publishes(screen_id, limit, offset)
async def restore_publish(self, screen_id: str, owner_id: int, version: int) -> int:
"""把历史版本的页面覆盖回草稿,不自动发布"""
async with self._lock(screen_id):
record = await self.get(screen_id, owner_id)
snapshot = await self._store.get_publish(screen_id, version)
if snapshot is None:
raise BizException(404, "发布版本不存在")
updated = await self._update_with_revision(
screen_id,
owner_id,
{
"page": snapshot.page,
"revision": record.revision + 1,
"updated_at": _now(),
},
record.revision,
)
await self._pin_files(owner_id, screen_id, snapshot.page)
return updated.revision
# ---- 线上(匿名) ----
async def _live(self, share_id: str) -> tuple[ScreenSummary, PublishSnapshot]:
"""线上版本:已下线 / 已删除 / 不存在都是 404;返回的是发布快照,不是草稿"""
summary = await self._store.get_by_share_id(share_id)
if (
summary is None
or summary.publish is None
or summary.publish.status != "published"
):
raise _not_found()
snapshot = await self._store.get_publish(
summary.screen_id, summary.publish.version
)
if snapshot is None:
raise _not_found()
return summary, snapshot
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, raw_vars: str | None = None
):
"""前端只传数据源 ID,请求地址取自已发布快照;不能做成"前端传 URL 后端转发",否则是匿名开放代理
raw_vars 是浏览器当前的变量值(JSON 对象),只代入 params 的值;缓存 key 用代入后的 params
"""
_, snapshot = await self._live(share_id)
source = next(
(
item
for item in snapshot.page.get("dataSource", [])
if isinstance(item, dict) and item.get("id") == datasource_id
),
None,
)
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,
json.dumps(params, sort_keys=True, ensure_ascii=False),
),
lambda: fetch_json(
str(source["url"]),
method,
params if isinstance(params, dict) else None,
headers if isinstance(headers, dict) else None,
),
)