feat:新增大屏存取发布接口
This commit is contained in:
@@ -195,7 +195,8 @@ class DataFileService:
|
||||
|
||||
async def delete_file(self, user_id: int, file_id: str) -> None:
|
||||
await self.get_file(user_id, file_id)
|
||||
await self.store.mark_deleted(file_id)
|
||||
# 同时撤销大屏对它的固定:文件没了,记录不该再停在"永不过期"
|
||||
await self.store.mark_deleted(file_id, datetime.now(UTC) + self._ttl)
|
||||
# Windows 上正在查询的 Parquet 删不掉,留给定时清理
|
||||
await asyncio.to_thread(shutil.rmtree, self.root / file_id, ignore_errors=True)
|
||||
|
||||
@@ -215,6 +216,21 @@ class DataFileService:
|
||||
raise BizException(404, NOT_FOUND)
|
||||
return records
|
||||
|
||||
async def sync_screen_files(
|
||||
self, user_id: int, screen_id: str, file_ids: set[str]
|
||||
) -> None:
|
||||
"""大屏保存后调用:它引用的文件固定住,不再引用的放开(重新按保留时长计时)"""
|
||||
current = await self.store.pinned_ids(screen_id)
|
||||
if added := file_ids - current:
|
||||
await self.store.pin(added, user_id, screen_id)
|
||||
if removed := current - file_ids:
|
||||
await self.store.unpin(screen_id, removed, datetime.now(UTC) + self._ttl)
|
||||
|
||||
async def release_screen_files(self, screen_id: str) -> None:
|
||||
"""大屏进回收站或被彻底删除:它固定的文件全部放开"""
|
||||
if current := await self.store.pinned_ids(screen_id):
|
||||
await self.store.unpin(screen_id, current, datetime.now(UTC) + self._ttl)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""应用退出时取消还在进行的分析,记录里留下的 pending 下次启动后读取时按失败返回"""
|
||||
for task in list(self._tasks):
|
||||
|
||||
+121
-5
@@ -18,6 +18,10 @@ from app.schemas.data_file import DataFileRecord, DataTable, FileInsights
|
||||
SOURCE_NAME = "source"
|
||||
|
||||
|
||||
# 被大屏固定的文件的占位过期时间:MongoDB 的 TTL 索引只认 expires_at,推到遥远的将来就不会被删
|
||||
PINNED_EXPIRES = datetime(9999, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
def table_path(root: Path, file_id: str, table: DataTable) -> Path:
|
||||
return root / file_id / table.parquet
|
||||
|
||||
@@ -43,7 +47,11 @@ class DataFileStore(Protocol):
|
||||
"""为会话预留 count 个连续的表号,返回第一个;表号从 1 开始,不会重复分配"""
|
||||
...
|
||||
|
||||
async def mark_deleted(self, file_id: str) -> None: ...
|
||||
async def mark_deleted(
|
||||
self, file_id: str, expires_at: datetime | None = None
|
||||
) -> None:
|
||||
"""给了 expires_at 就同时撤销所有大屏对它的固定,并把过期时间改成它(被固定的文件不能停在 9999 年)"""
|
||||
...
|
||||
|
||||
async def update_insights(self, file_id: str, insights: FileInsights) -> None:
|
||||
"""记录已过期或不存在时什么都不做"""
|
||||
@@ -53,6 +61,20 @@ class DataFileStore(Protocol):
|
||||
"""未过期且未删除的 file_id,清理目录时用"""
|
||||
...
|
||||
|
||||
async def pin(self, file_ids: set[str], owner_id: int, screen_id: str) -> None:
|
||||
"""让 screen_id 固定这些文件(只对 owner 名下、未过期、未删除的生效),被固定的文件不再过期"""
|
||||
...
|
||||
|
||||
async def unpin(
|
||||
self, screen_id: str, file_ids: set[str], expires_at: datetime
|
||||
) -> None:
|
||||
"""撤销 screen_id 对这些文件的固定;不再被任何大屏固定的文件,过期时间改为 expires_at"""
|
||||
...
|
||||
|
||||
async def pinned_ids(self, screen_id: str) -> set[str]:
|
||||
"""被 screen_id 固定的文件,包括已删除的(它们也要撤销固定)"""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryDataFileStore:
|
||||
def __init__(self) -> None:
|
||||
@@ -90,9 +112,14 @@ class InMemoryDataFileStore:
|
||||
self._table_numbers[conversation_id] = first + count - 1
|
||||
return first
|
||||
|
||||
async def mark_deleted(self, file_id: str) -> None:
|
||||
async def mark_deleted(
|
||||
self, file_id: str, expires_at: datetime | None = None
|
||||
) -> None:
|
||||
if (record := self._records.get(file_id)) is not None:
|
||||
self._records[file_id] = record.model_copy(update={"deleted": True})
|
||||
changes: dict = {"deleted": True}
|
||||
if expires_at is not None:
|
||||
changes.update(pinned_by=[], expires_at=expires_at)
|
||||
self._records[file_id] = record.model_copy(update=changes)
|
||||
|
||||
async def update_insights(self, file_id: str, insights: FileInsights) -> None:
|
||||
if (record := self._records.get(file_id)) is not None:
|
||||
@@ -102,6 +129,37 @@ class InMemoryDataFileStore:
|
||||
self._purge_expired()
|
||||
return {key for key, record in self._records.items() if not record.deleted}
|
||||
|
||||
async def pin(self, file_ids, owner_id, screen_id):
|
||||
self._purge_expired()
|
||||
for file_id in file_ids:
|
||||
record = self._records.get(file_id)
|
||||
if record is None or record.deleted or record.owner_id != owner_id:
|
||||
continue
|
||||
pinned_by = record.pinned_by
|
||||
if screen_id not in pinned_by:
|
||||
pinned_by = [*pinned_by, screen_id]
|
||||
self._records[file_id] = record.model_copy(
|
||||
update={"pinned_by": pinned_by, "expires_at": PINNED_EXPIRES}
|
||||
)
|
||||
|
||||
async def unpin(self, screen_id, file_ids, expires_at):
|
||||
for file_id in file_ids:
|
||||
record = self._records.get(file_id)
|
||||
if record is None or screen_id not in record.pinned_by:
|
||||
continue
|
||||
pinned_by = [item for item in record.pinned_by if item != screen_id]
|
||||
changes = {"pinned_by": pinned_by}
|
||||
if not pinned_by:
|
||||
changes["expires_at"] = expires_at
|
||||
self._records[file_id] = record.model_copy(update=changes)
|
||||
|
||||
async def pinned_ids(self, screen_id):
|
||||
return {
|
||||
key
|
||||
for key, record in self._records.items()
|
||||
if screen_id in record.pinned_by
|
||||
}
|
||||
|
||||
|
||||
class MongoDataFileStore:
|
||||
def __init__(self, db: Any, prefix: str = ""):
|
||||
@@ -117,6 +175,8 @@ class MongoDataFileStore:
|
||||
await self._files.create_index(
|
||||
[("conversation_id", ASCENDING), ("created_at", ASCENDING)]
|
||||
)
|
||||
# 按大屏找它固定的文件
|
||||
await self._files.create_index("pinned_by")
|
||||
|
||||
async def insert(self, record: DataFileRecord) -> None:
|
||||
await self._files.insert_one({"_id": record.file_id, **record.model_dump()})
|
||||
@@ -149,8 +209,13 @@ class MongoDataFileStore:
|
||||
)
|
||||
return document["tables"] - count + 1
|
||||
|
||||
async def mark_deleted(self, file_id: str) -> None:
|
||||
await self._files.update_one({"_id": file_id}, {"$set": {"deleted": True}})
|
||||
async def mark_deleted(
|
||||
self, file_id: str, expires_at: datetime | None = None
|
||||
) -> None:
|
||||
changes: dict = {"deleted": True}
|
||||
if expires_at is not None:
|
||||
changes.update(pinned_by=[], expires_at=expires_at)
|
||||
await self._files.update_one({"_id": file_id}, {"$set": changes})
|
||||
|
||||
async def update_insights(self, file_id: str, insights: FileInsights) -> None:
|
||||
await self._files.update_one(
|
||||
@@ -164,6 +229,57 @@ class MongoDataFileStore:
|
||||
)
|
||||
)
|
||||
|
||||
async def pin(self, file_ids, owner_id, screen_id):
|
||||
if not file_ids:
|
||||
return
|
||||
await self._files.update_many(
|
||||
{
|
||||
"_id": {"$in": list(file_ids)},
|
||||
"owner_id": owner_id,
|
||||
"deleted": False,
|
||||
"expires_at": {"$gt": _now()},
|
||||
},
|
||||
{
|
||||
"$addToSet": {"pinned_by": screen_id},
|
||||
"$set": {"expires_at": PINNED_EXPIRES},
|
||||
},
|
||||
)
|
||||
|
||||
async def unpin(self, screen_id, file_ids, expires_at):
|
||||
if not file_ids:
|
||||
return
|
||||
# 一条聚合管道更新:去掉这个大屏,且只有数组因此变空时才重新计时。
|
||||
# 单文档原子,不会停在"数组空了但仍是 9999 年";只改确实固定了它的文档
|
||||
await self._files.update_many(
|
||||
{"_id": {"$in": list(file_ids)}, "pinned_by": screen_id},
|
||||
[
|
||||
{
|
||||
"$set": {
|
||||
"pinned_by": {
|
||||
"$filter": {
|
||||
"input": "$pinned_by",
|
||||
"cond": {"$ne": ["$$this", screen_id]},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"expires_at": {
|
||||
"$cond": [
|
||||
{"$eq": [{"$size": "$pinned_by"}, 0]},
|
||||
expires_at,
|
||||
"$expires_at",
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
async def pinned_ids(self, screen_id):
|
||||
return set(await self._files.distinct("_id", {"pinned_by": screen_id}))
|
||||
|
||||
|
||||
def _record(document: dict[str, Any]) -> DataFileRecord:
|
||||
return DataFileRecord.model_validate(
|
||||
|
||||
Reference in New Issue
Block a user