Files
my-v0-server/app/services/screen_mongo.py
T

239 lines
8.6 KiB
Python

"""大屏存储的 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})