242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
"""大屏存储:协议和进程内存实现;MongoDB 实现见 screen_mongo
|
|
|
|
内存实现只给测试用——大屏数据不能丢,没配 MongoDB 时 screens 接口直接返回 503
|
|
所有查询都带 owner_id:别人的大屏与不存在同样返回 None
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Protocol
|
|
|
|
from app.schemas.saved_screen import (
|
|
ListStatus,
|
|
PublishEntry,
|
|
PublishSnapshot,
|
|
ScreenRecord,
|
|
ScreenSummary,
|
|
SortField,
|
|
)
|
|
|
|
|
|
def canvas_size(canvas: Any) -> dict[str, Any] | None:
|
|
"""列表卡片只需要画布宽高"""
|
|
if not isinstance(canvas, dict):
|
|
return None
|
|
return {key: canvas[key] for key in ("width", "height") if key in canvas}
|
|
|
|
|
|
def summarize(record: ScreenRecord) -> ScreenSummary:
|
|
"""去掉页面,只保留画布宽高"""
|
|
data = record.model_dump(exclude={"page"})
|
|
data["canvas"] = canvas_size(record.page.get("canvas"))
|
|
return ScreenSummary.model_validate(data)
|
|
|
|
|
|
class ScreenStore(Protocol):
|
|
async def insert(self, record: ScreenRecord) -> None: ...
|
|
|
|
async def get(
|
|
self, screen_id: str, owner_id: int, *, deleted: bool = False
|
|
) -> ScreenRecord | None:
|
|
"""带页面的记录;deleted 为 True 时只找回收站里的"""
|
|
...
|
|
|
|
async def get_summary(
|
|
self, screen_id: str, owner_id: int, *, deleted: bool = False
|
|
) -> ScreenSummary | None: ...
|
|
|
|
async def update(
|
|
self,
|
|
screen_id: str,
|
|
owner_id: int,
|
|
changes: dict[str, Any],
|
|
*,
|
|
revision: int | None = None,
|
|
where: dict[str, Any] | None = None,
|
|
deleted: bool = False,
|
|
) -> ScreenSummary | None:
|
|
"""一步完成"匹配并更新";不匹配返回 None
|
|
|
|
给了 revision 就要求库里的 revision 与之相等;where 是额外的等值条件,键可以带点,
|
|
如 {"publish.version": 3}、{"publish": None}——更新是基于先前读到的值做的,值变了就不能盖上去
|
|
"""
|
|
...
|
|
|
|
async def search(
|
|
self,
|
|
owner_id: int,
|
|
*,
|
|
keyword: str | None,
|
|
status: ListStatus | None,
|
|
sort: SortField,
|
|
descending: bool,
|
|
limit: int,
|
|
offset: int,
|
|
deleted: bool,
|
|
) -> tuple[list[ScreenSummary], int]: ...
|
|
|
|
async def get_by_share_id(self, share_id: str) -> ScreenSummary | None:
|
|
"""未删除的大屏;是否已下线由调用方看 publish.status"""
|
|
...
|
|
|
|
async def list_expired_trash(
|
|
self, deleted_before: datetime, limit: int
|
|
) -> list[ScreenSummary]:
|
|
"""所有人的回收站里 deleted_at 早于 deleted_before 的大屏"""
|
|
...
|
|
|
|
async def purge(self, screen_id: str, owner_id: int) -> bool:
|
|
"""彻底删除回收站里的大屏;已经被还原(不在回收站)或不存在时返回 False"""
|
|
...
|
|
|
|
async def delete_publishes(self, screen_id: str) -> None: ...
|
|
|
|
async def last_publish_version(self, screen_id: str) -> int: ...
|
|
|
|
async def insert_publish(self, snapshot: PublishSnapshot) -> bool:
|
|
"""同一大屏同一版本号已存在时返回 False"""
|
|
...
|
|
|
|
async def get_publish(
|
|
self, screen_id: str, version: int
|
|
) -> PublishSnapshot | None: ...
|
|
|
|
async def list_publishes(
|
|
self, screen_id: str, limit: int, offset: int
|
|
) -> tuple[list[PublishEntry], int]:
|
|
"""版本号倒序"""
|
|
...
|
|
|
|
async def delete_publish(self, screen_id: str, version: int) -> None: ...
|
|
|
|
|
|
class InMemoryScreenStore:
|
|
def __init__(self) -> None:
|
|
self._screens: dict[str, ScreenRecord] = {}
|
|
self._publishes: dict[tuple[str, int], PublishSnapshot] = {}
|
|
|
|
def _find(
|
|
self, screen_id: str, owner_id: int, deleted: bool
|
|
) -> ScreenRecord | None:
|
|
record = self._screens.get(screen_id)
|
|
if record is None or record.owner_id != owner_id:
|
|
return None
|
|
return record if (record.deleted_at is not None) == deleted else None
|
|
|
|
async def insert(self, record: ScreenRecord) -> None:
|
|
self._screens[record.screen_id] = record.model_copy(deep=True)
|
|
|
|
async def get(self, screen_id, owner_id, *, deleted=False):
|
|
record = self._find(screen_id, owner_id, deleted)
|
|
return None if record is None else record.model_copy(deep=True)
|
|
|
|
async def get_summary(self, screen_id, owner_id, *, deleted=False):
|
|
record = self._find(screen_id, owner_id, deleted)
|
|
return None if record is None else summarize(record)
|
|
|
|
async def update(
|
|
self, screen_id, owner_id, changes, *, revision=None, where=None, deleted=False
|
|
):
|
|
record = self._find(screen_id, owner_id, deleted)
|
|
if record is None or (revision is not None and record.revision != revision):
|
|
return None
|
|
if where is not None and not _matches(record, where):
|
|
return None
|
|
updated = record.model_copy(update=changes, deep=True)
|
|
self._screens[screen_id] = updated
|
|
return summarize(updated)
|
|
|
|
async def search(
|
|
self, owner_id, *, keyword, status, sort, descending, limit, offset, deleted
|
|
):
|
|
records = [
|
|
record
|
|
for record in self._screens.values()
|
|
if record.owner_id == owner_id
|
|
and (record.deleted_at is not None) == deleted
|
|
and (keyword is None or keyword.lower() in record.name.lower())
|
|
and _status_matches(record, status)
|
|
]
|
|
# 排序字段相同时按 ID 兜底,翻页才稳定
|
|
records.sort(key=lambda r: (getattr(r, sort), r.screen_id), reverse=descending)
|
|
return [summarize(r) for r in records[offset : offset + limit]], len(records)
|
|
|
|
async def get_by_share_id(self, share_id):
|
|
for record in self._screens.values():
|
|
if (
|
|
record.deleted_at is None
|
|
and record.publish is not None
|
|
and record.publish.share_id == share_id
|
|
):
|
|
return summarize(record)
|
|
return None
|
|
|
|
async def list_expired_trash(self, deleted_before, limit):
|
|
expired = sorted(
|
|
(
|
|
r
|
|
for r in self._screens.values()
|
|
if r.deleted_at is not None and r.deleted_at < deleted_before
|
|
),
|
|
key=lambda r: r.deleted_at,
|
|
)
|
|
return [summarize(r) for r in expired[:limit]]
|
|
|
|
async def purge(self, screen_id, owner_id):
|
|
if self._find(screen_id, owner_id, True) is None:
|
|
return False
|
|
del self._screens[screen_id]
|
|
return True
|
|
|
|
async def delete_publishes(self, screen_id):
|
|
for key in [key for key in self._publishes if key[0] == screen_id]:
|
|
del self._publishes[key]
|
|
|
|
async def last_publish_version(self, screen_id):
|
|
return max(
|
|
(version for sid, version in self._publishes if sid == screen_id), default=0
|
|
)
|
|
|
|
async def insert_publish(self, snapshot):
|
|
key = (snapshot.screen_id, snapshot.version)
|
|
if key in self._publishes:
|
|
return False
|
|
self._publishes[key] = snapshot.model_copy(deep=True)
|
|
return True
|
|
|
|
async def get_publish(self, screen_id, version):
|
|
snapshot = self._publishes.get((screen_id, version))
|
|
return None if snapshot is None else snapshot.model_copy(deep=True)
|
|
|
|
async def list_publishes(self, screen_id, limit, offset):
|
|
snapshots = sorted(
|
|
(s for (sid, _), s in self._publishes.items() if sid == screen_id),
|
|
key=lambda s: s.version,
|
|
reverse=True,
|
|
)
|
|
entries = [
|
|
PublishEntry.model_validate(s.model_dump(exclude={"page"}))
|
|
for s in snapshots[offset : offset + limit]
|
|
]
|
|
return entries, len(snapshots)
|
|
|
|
async def delete_publish(self, screen_id, version):
|
|
self._publishes.pop((screen_id, version), None)
|
|
|
|
|
|
def _matches(record: ScreenRecord, where: dict[str, Any]) -> bool:
|
|
for path, expected in where.items():
|
|
value: Any = record
|
|
for part in path.split("."):
|
|
value = None if value is None else getattr(value, part, None)
|
|
if value != expected:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _status_matches(record: ScreenSummary, status: ListStatus | None) -> bool:
|
|
if status is None:
|
|
return True
|
|
if status == "draft":
|
|
return record.publish is None
|
|
return record.publish is not None and record.publish.status == status
|