feat:新增大屏存取发布接口
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""大屏存储的 MongoDB 实现,方法语义与内存实现一致
|
||||
|
||||
集合:screens(草稿,一屏一条)、screen_publishes(发布快照,只增不改)
|
||||
"""
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from bson.codec_options import CodecOptions
|
||||
from bson.errors import InvalidDocument
|
||||
from pydantic import BaseModel
|
||||
from pymongo import ASCENDING, DESCENDING, ReturnDocument
|
||||
from pymongo.errors import DocumentTooLarge, DuplicateKeyError
|
||||
|
||||
from app.core.exceptions import BizException
|
||||
from app.schemas.saved_screen import (
|
||||
PublishEntry,
|
||||
PublishSnapshot,
|
||||
ScreenRecord,
|
||||
ScreenSummary,
|
||||
)
|
||||
from app.services.screen_store import canvas_size
|
||||
|
||||
# 列表和元数据更新不需要页面,只取画布;Mongo 的投影不能同时"排除 page"和"包含 page.canvas",所以列字段
|
||||
SUMMARY_PROJECTION = {
|
||||
"owner_id": 1,
|
||||
"name": 1,
|
||||
"thumbnail": 1,
|
||||
"page.canvas": 1,
|
||||
"revision": 1,
|
||||
"created_at": 1,
|
||||
"updated_at": 1,
|
||||
"deleted_at": 1,
|
||||
"publish": 1,
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _storable():
|
||||
"""页面里的超大整数、含 NUL 的字段名、编码后超过 16 MB 的文档,驱动会抛各种非业务异常
|
||||
|
||||
10 MB 的 JSON 编码成 BSON 后会涨(数组的下标要当字符串键存),所以体积上限靠这里兜底
|
||||
"""
|
||||
try:
|
||||
yield
|
||||
except (OverflowError, InvalidDocument, DocumentTooLarge):
|
||||
raise BizException(
|
||||
422, "大屏数据无法保存:数字超出范围、字段名不合法或体积过大"
|
||||
) from None
|
||||
|
||||
|
||||
# update 自己拼的条件,调用方的 where 不许覆盖
|
||||
RESERVED_FILTER_KEYS = {"_id", "owner_id", "deleted_at", "revision"}
|
||||
|
||||
|
||||
def _owned(screen_id: str, owner_id: int, deleted: bool) -> dict[str, Any]:
|
||||
return {
|
||||
"_id": screen_id,
|
||||
"owner_id": owner_id,
|
||||
"deleted_at": {"$ne": None} if deleted else None,
|
||||
}
|
||||
|
||||
|
||||
def _encode(value: Any) -> Any:
|
||||
return value.model_dump() if isinstance(value, BaseModel) else value
|
||||
|
||||
|
||||
def _summary(document: dict[str, Any]) -> ScreenSummary:
|
||||
canvas = (document.get("page") or {}).get("canvas")
|
||||
data = {k: v for k, v in document.items() if k not in ("_id", "page")}
|
||||
return ScreenSummary.model_validate(
|
||||
{**data, "screen_id": document["_id"], "canvas": canvas_size(canvas)}
|
||||
)
|
||||
|
||||
|
||||
class MongoScreenStore:
|
||||
def __init__(self, db: Any, prefix: str = ""):
|
||||
# 读回的时间统一带 UTC 时区;集合名前缀用于测试隔离
|
||||
options = CodecOptions(tz_aware=True, tzinfo=UTC)
|
||||
self._screens = db.get_collection(f"{prefix}screens", codec_options=options)
|
||||
self._publishes = db.get_collection(
|
||||
f"{prefix}screen_publishes", codec_options=options
|
||||
)
|
||||
|
||||
async def ensure_indexes(self) -> None:
|
||||
await self._screens.create_index(
|
||||
[
|
||||
("owner_id", ASCENDING),
|
||||
("deleted_at", ASCENDING),
|
||||
("updated_at", DESCENDING),
|
||||
]
|
||||
)
|
||||
# 分享链接唯一;没发布过的大屏没有 publish.share_id,不进索引
|
||||
await self._screens.create_index(
|
||||
"publish.share_id",
|
||||
unique=True,
|
||||
partialFilterExpression={"publish.share_id": {"$type": "string"}},
|
||||
)
|
||||
await self._publishes.create_index(
|
||||
[("screen_id", ASCENDING), ("version", DESCENDING)], unique=True
|
||||
)
|
||||
# 回收站过期清理按 deleted_at 跨用户扫描;没删除的不进索引
|
||||
await self._screens.create_index(
|
||||
"deleted_at", partialFilterExpression={"deleted_at": {"$type": "date"}}
|
||||
)
|
||||
|
||||
async def insert(self, record: ScreenRecord) -> None:
|
||||
document = record.model_dump(exclude={"screen_id", "canvas"})
|
||||
with _storable():
|
||||
await self._screens.insert_one({"_id": record.screen_id, **document})
|
||||
|
||||
async def get(self, screen_id, owner_id, *, deleted=False):
|
||||
document = await self._screens.find_one(_owned(screen_id, owner_id, deleted))
|
||||
if document is None:
|
||||
return None
|
||||
return ScreenRecord.model_validate(
|
||||
{**_summary(document).model_dump(), "page": document["page"]}
|
||||
)
|
||||
|
||||
async def get_summary(self, screen_id, owner_id, *, deleted=False):
|
||||
document = await self._screens.find_one(
|
||||
_owned(screen_id, owner_id, deleted), SUMMARY_PROJECTION
|
||||
)
|
||||
return None if document is None else _summary(document)
|
||||
|
||||
async def update(
|
||||
self, screen_id, owner_id, changes, *, revision=None, where=None, deleted=False
|
||||
):
|
||||
query = _owned(screen_id, owner_id, deleted)
|
||||
if revision is not None:
|
||||
query["revision"] = revision
|
||||
if clash := RESERVED_FILTER_KEYS & (where or {}).keys():
|
||||
raise ValueError(f"where 不能覆盖所有权和版本条件:{sorted(clash)}")
|
||||
query.update(where or {})
|
||||
with _storable():
|
||||
document = await self._screens.find_one_and_update(
|
||||
query,
|
||||
{"$set": {key: _encode(value) for key, value in changes.items()}},
|
||||
projection=SUMMARY_PROJECTION,
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
return None if document is None else _summary(document)
|
||||
|
||||
async def search(
|
||||
self, owner_id, *, keyword, status, sort, descending, limit, offset, deleted
|
||||
):
|
||||
query: dict[str, Any] = {
|
||||
"owner_id": owner_id,
|
||||
"deleted_at": {"$ne": None} if deleted else None,
|
||||
}
|
||||
if keyword:
|
||||
query["name"] = {"$regex": re.escape(keyword), "$options": "i"}
|
||||
if status == "draft":
|
||||
query["publish"] = None
|
||||
elif status is not None:
|
||||
query["publish.status"] = status
|
||||
direction = DESCENDING if descending else ASCENDING
|
||||
documents = await (
|
||||
self._screens.find(query, SUMMARY_PROJECTION)
|
||||
.sort([(sort, direction), ("_id", direction)])
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.to_list()
|
||||
)
|
||||
return [_summary(d) for d in documents], await self._screens.count_documents(
|
||||
query
|
||||
)
|
||||
|
||||
async def get_by_share_id(self, share_id):
|
||||
document = await self._screens.find_one(
|
||||
{"publish.share_id": share_id, "deleted_at": None}, SUMMARY_PROJECTION
|
||||
)
|
||||
return None if document is None else _summary(document)
|
||||
|
||||
async def list_expired_trash(self, deleted_before, limit):
|
||||
documents = await (
|
||||
self._screens.find(
|
||||
# 带 $type 才蕴含部分索引的过滤条件,查询才用得上索引
|
||||
{"deleted_at": {"$type": "date", "$lt": deleted_before}},
|
||||
SUMMARY_PROJECTION,
|
||||
)
|
||||
.sort("deleted_at", ASCENDING)
|
||||
.limit(limit)
|
||||
.to_list()
|
||||
)
|
||||
return [_summary(d) for d in documents]
|
||||
|
||||
async def purge(self, screen_id, owner_id):
|
||||
# 带"仍在回收站"的条件:清理期间被还原的大屏不能删
|
||||
result = await self._screens.delete_one(_owned(screen_id, owner_id, True))
|
||||
return result.deleted_count == 1
|
||||
|
||||
async def delete_publishes(self, screen_id):
|
||||
await self._publishes.delete_many({"screen_id": screen_id})
|
||||
|
||||
async def last_publish_version(self, screen_id):
|
||||
document = await self._publishes.find_one(
|
||||
{"screen_id": screen_id}, {"version": 1}, sort=[("version", DESCENDING)]
|
||||
)
|
||||
return 0 if document is None else document["version"]
|
||||
|
||||
async def insert_publish(self, snapshot):
|
||||
try:
|
||||
with _storable():
|
||||
await self._publishes.insert_one(snapshot.model_dump())
|
||||
except DuplicateKeyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def get_publish(self, screen_id, version):
|
||||
document = await self._publishes.find_one(
|
||||
{"screen_id": screen_id, "version": version}
|
||||
)
|
||||
if document is None:
|
||||
return None
|
||||
return PublishSnapshot.model_validate(
|
||||
{k: v for k, v in document.items() if k != "_id"}
|
||||
)
|
||||
|
||||
async def list_publishes(self, screen_id, limit, offset):
|
||||
query = {"screen_id": screen_id}
|
||||
documents = await (
|
||||
self._publishes.find(query, {"page": 0})
|
||||
.sort("version", DESCENDING)
|
||||
.skip(offset)
|
||||
.limit(limit)
|
||||
.to_list()
|
||||
)
|
||||
entries = [
|
||||
PublishEntry.model_validate({k: v for k, v in d.items() if k != "_id"})
|
||||
for d in documents
|
||||
]
|
||||
return entries, await self._publishes.count_documents(query)
|
||||
|
||||
async def delete_publish(self, screen_id, version):
|
||||
await self._publishes.delete_one({"screen_id": screen_id, "version": version})
|
||||
Reference in New Issue
Block a user