"""大屏存储:协议和进程内存实现;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