feat:新增大屏存取发布接口

This commit is contained in:
2026-10-11 08:27:30 -07:00
parent dadd797391
commit afc07ba695
23 changed files with 2672 additions and 36 deletions
+241
View File
@@ -0,0 +1,241 @@
"""大屏存储:协议和进程内存实现;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