339 lines
13 KiB
Python
339 lines
13 KiB
Python
"""回收站服务:目录/文档/知识库删除后的暂存、恢复与彻底删除。
|
|
|
|
- 删除 = 软删除(deleted_at 时间戳),文件保留
|
|
- 恢复 = 清除删除标记(目录恢复时整批恢复同批文档)
|
|
- 彻底删除 = 物理删除文件 + 删除数据库记录
|
|
- 超过保留期(默认 3 天)由定时任务自动彻底删除
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy import delete as sa_delete
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.errors import NotFoundError
|
|
from app.core.logging import get_logger
|
|
from app.models.document import Document
|
|
from app.models.document_category import DocumentCategory
|
|
from app.models.knowledge_base import KnowledgeBase
|
|
from app.models.user import User
|
|
from app.services.doc_service import DocumentService
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_TIME_FMT = "%Y-%m-%d %H:%M:%S"
|
|
_DOC_RESTORE_STATUS = "READY" # 恢复文档时的状态(文件未删,可直接就绪)
|
|
|
|
|
|
class RecycleBinService:
|
|
def __init__(self, session: Session) -> None:
|
|
self._session = session
|
|
self._doc_svc = DocumentService(session)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 查询
|
|
# ------------------------------------------------------------------
|
|
|
|
def list_items(self, user: User) -> dict:
|
|
"""列出回收站内容(按用户隔离),分三类返回。"""
|
|
settings = get_settings()
|
|
now = datetime.now()
|
|
deadline = now - timedelta(days=settings.recycle_bin_retention_days)
|
|
|
|
def days_left(deleted_at: str) -> int:
|
|
try:
|
|
d = datetime.strptime(deleted_at, _TIME_FMT)
|
|
except (ValueError, TypeError):
|
|
return 0
|
|
return max(0, (d - deadline).days)
|
|
|
|
# 文档
|
|
doc_stmt = (
|
|
select(Document)
|
|
.join(KnowledgeBase, Document.knowledge_base_id == KnowledgeBase.id)
|
|
.where(
|
|
Document.status == "DELETED",
|
|
Document.deleted_at.is_not(None),
|
|
Document.user_id == user.id,
|
|
)
|
|
.order_by(Document.deleted_at.desc())
|
|
)
|
|
documents = []
|
|
for d in self._session.scalars(doc_stmt).all():
|
|
documents.append({
|
|
"id": d.id,
|
|
"name": d.title or d.original_filename,
|
|
"kb_id": d.knowledge_base_id,
|
|
"kb_name": d.knowledge_base.name if d.knowledge_base else "",
|
|
"deleted_at": d.deleted_at,
|
|
"days_left": days_left(d.deleted_at),
|
|
"file_size": d.file_size,
|
|
})
|
|
|
|
# 分类
|
|
cat_stmt = (
|
|
select(DocumentCategory)
|
|
.join(KnowledgeBase, DocumentCategory.knowledge_base_id == KnowledgeBase.id)
|
|
.where(
|
|
DocumentCategory.deleted_at.is_not(None),
|
|
KnowledgeBase.user_id == user.id,
|
|
)
|
|
.order_by(DocumentCategory.deleted_at.desc())
|
|
)
|
|
categories = []
|
|
for c in self._session.scalars(cat_stmt).all():
|
|
categories.append({
|
|
"id": c.id,
|
|
"name": c.name,
|
|
"kb_id": c.knowledge_base_id,
|
|
"kb_name": c.knowledge_base.name if c.knowledge_base else "",
|
|
"deleted_at": c.deleted_at,
|
|
"days_left": days_left(c.deleted_at),
|
|
})
|
|
|
|
# 知识库
|
|
kb_stmt = (
|
|
select(KnowledgeBase)
|
|
.where(
|
|
KnowledgeBase.status == "DELETED",
|
|
KnowledgeBase.deleted_at.is_not(None),
|
|
KnowledgeBase.user_id == user.id,
|
|
)
|
|
.order_by(KnowledgeBase.deleted_at.desc())
|
|
)
|
|
knowledge_bases = []
|
|
for kb in self._session.scalars(kb_stmt).all():
|
|
knowledge_bases.append({
|
|
"id": kb.id,
|
|
"name": kb.name,
|
|
"deleted_at": kb.deleted_at,
|
|
"days_left": days_left(kb.deleted_at),
|
|
})
|
|
|
|
return {
|
|
"documents": documents,
|
|
"categories": categories,
|
|
"knowledge_bases": knowledge_bases,
|
|
"retention_days": settings.recycle_bin_retention_days,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# 恢复
|
|
# ------------------------------------------------------------------
|
|
|
|
def restore_document(self, doc_id: str, user: User) -> None:
|
|
doc = self._get_deleted_doc(doc_id, user)
|
|
doc.status = _DOC_RESTORE_STATUS
|
|
doc.deleted_at = None
|
|
self._session.commit()
|
|
|
|
def restore_category(self, cat_id: str, user: User) -> None:
|
|
"""恢复分类(连同同批删除的后代分类与文档)。"""
|
|
cat = self._get_deleted_category(cat_id, user)
|
|
batch_time = cat.deleted_at
|
|
|
|
# 后代分类中同批删除的一并恢复
|
|
if cat.path:
|
|
stmt = select(DocumentCategory).where(
|
|
DocumentCategory.knowledge_base_id == cat.knowledge_base_id,
|
|
DocumentCategory.path.startswith(cat.path),
|
|
DocumentCategory.deleted_at == batch_time,
|
|
)
|
|
for c in self._session.scalars(stmt).all():
|
|
c.deleted_at = None
|
|
cat.deleted_at = None
|
|
|
|
# 同批删除的文档一并恢复
|
|
doc_stmt = select(Document).where(
|
|
Document.deleted_at == batch_time,
|
|
Document.user_id == user.id,
|
|
)
|
|
for d in self._session.scalars(doc_stmt).all():
|
|
if d.category_id and self._in_category_tree(d.category_id, cat):
|
|
d.status = _DOC_RESTORE_STATUS
|
|
d.deleted_at = None
|
|
|
|
self._session.commit()
|
|
|
|
def restore_kb(self, kb_id: str, user: User) -> None:
|
|
kb = self._get_deleted_kb(kb_id, user)
|
|
kb.status = "active"
|
|
kb.deleted_at = None
|
|
self._session.commit()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 彻底删除
|
|
# ------------------------------------------------------------------
|
|
|
|
def purge_document(self, doc_id: str, user: User) -> None:
|
|
doc = self._get_deleted_doc(doc_id, user)
|
|
self._purge_doc_rows(doc)
|
|
self._session.commit()
|
|
|
|
def _purge_doc_rows(self, doc: Document) -> None:
|
|
"""物理删除文件 + 关联访问日志 + 记录。"""
|
|
from app.models.access_log import AccessLog
|
|
|
|
self._doc_svc.purge_files(doc)
|
|
self._session.execute(
|
|
sa_delete(AccessLog).where(AccessLog.document_id == doc.id)
|
|
)
|
|
self._session.delete(doc)
|
|
|
|
def purge_category(self, cat_id: str, user: User) -> None:
|
|
"""彻底删除分类:连同同批删除的后代分类与文档(文件+记录)。"""
|
|
cat = self._get_deleted_category(cat_id, user)
|
|
batch_time = cat.deleted_at
|
|
kb_id = cat.knowledge_base_id
|
|
|
|
# 收集同批删除的分类 ID 集合(自身 + 后代)
|
|
cat_ids = [cat_id]
|
|
if cat.path:
|
|
stmt = select(DocumentCategory).where(
|
|
DocumentCategory.knowledge_base_id == kb_id,
|
|
DocumentCategory.path.startswith(cat.path),
|
|
DocumentCategory.deleted_at == batch_time,
|
|
)
|
|
cat_ids += [c.id for c in self._session.scalars(stmt).all() if c.id != cat_id]
|
|
|
|
# 同批删除的文档:物理删文件 + 删记录
|
|
doc_stmt = select(Document).where(
|
|
Document.deleted_at == batch_time,
|
|
Document.category_id.in_(cat_ids),
|
|
Document.user_id == user.id,
|
|
)
|
|
for d in self._session.scalars(doc_stmt).all():
|
|
self._purge_doc_rows(d)
|
|
|
|
# 其他仍引用这些分类的文档(各自单独删除的):解除关联,留在回收站
|
|
ref_docs = select(Document).where(Document.category_id.in_(cat_ids))
|
|
for d in self._session.scalars(ref_docs).all():
|
|
d.category_id = None
|
|
|
|
# 删除分类记录
|
|
for cid in cat_ids:
|
|
c = self._session.get(DocumentCategory, cid)
|
|
if c:
|
|
self._session.delete(c)
|
|
|
|
self._session.commit()
|
|
|
|
def purge_kb(self, kb_id: str, user: User) -> None:
|
|
"""彻底删除知识库:其下所有文档文件 + 全部记录。"""
|
|
from app.models.access_log import AccessLog
|
|
|
|
kb = self._get_deleted_kb(kb_id, user)
|
|
|
|
doc_stmt = select(Document).where(Document.knowledge_base_id == kb_id)
|
|
for d in self._session.scalars(doc_stmt).all():
|
|
self._purge_doc_rows(d)
|
|
|
|
cat_stmt = select(DocumentCategory).where(
|
|
DocumentCategory.knowledge_base_id == kb_id
|
|
)
|
|
for c in self._session.scalars(cat_stmt).all():
|
|
self._session.delete(c)
|
|
|
|
# 访问日志
|
|
self._session.execute(sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb_id))
|
|
|
|
self._session.delete(kb)
|
|
self._session.commit()
|
|
|
|
# ------------------------------------------------------------------
|
|
# 自动清理(定时任务调用)
|
|
# ------------------------------------------------------------------
|
|
|
|
def purge_expired(self) -> int:
|
|
"""彻底删除所有超过保留期的回收站内容。返回清理的条目数。"""
|
|
settings = get_settings()
|
|
cutoff = (datetime.now() - timedelta(days=settings.recycle_bin_retention_days)).strftime(_TIME_FMT)
|
|
count = 0
|
|
|
|
# 过期文档
|
|
doc_stmt = select(Document).where(
|
|
Document.status == "DELETED",
|
|
Document.deleted_at.is_not(None),
|
|
Document.deleted_at < cutoff,
|
|
)
|
|
for d in self._session.scalars(doc_stmt).all():
|
|
self._purge_doc_rows(d)
|
|
count += 1
|
|
|
|
# 过期分类(解除所有仍引用它的文档关联,再删除)
|
|
cat_stmt = select(DocumentCategory).where(
|
|
DocumentCategory.deleted_at.is_not(None),
|
|
DocumentCategory.deleted_at < cutoff,
|
|
)
|
|
expired_cats = list(self._session.scalars(cat_stmt).all())
|
|
for c in expired_cats:
|
|
ref_docs = select(Document).where(Document.category_id == c.id)
|
|
for d in self._session.scalars(ref_docs).all():
|
|
d.category_id = None
|
|
self._session.delete(c)
|
|
count += 1
|
|
|
|
# 过期知识库
|
|
from app.models.access_log import AccessLog
|
|
|
|
kb_stmt = select(KnowledgeBase).where(
|
|
KnowledgeBase.status == "DELETED",
|
|
KnowledgeBase.deleted_at.is_not(None),
|
|
KnowledgeBase.deleted_at < cutoff,
|
|
)
|
|
for kb in self._session.scalars(kb_stmt).all():
|
|
doc_all = select(Document).where(Document.knowledge_base_id == kb.id)
|
|
for d in self._session.scalars(doc_all).all():
|
|
self._purge_doc_rows(d)
|
|
cat_all = select(DocumentCategory).where(
|
|
DocumentCategory.knowledge_base_id == kb.id
|
|
)
|
|
for c in self._session.scalars(cat_all).all():
|
|
self._session.delete(c)
|
|
self._session.execute(
|
|
sa_delete(AccessLog).where(AccessLog.knowledge_base_id == kb.id)
|
|
)
|
|
self._session.delete(kb)
|
|
count += 1
|
|
|
|
if count:
|
|
self._session.commit()
|
|
logger.info("回收站自动清理:%d 项已彻底删除", count)
|
|
return count
|
|
|
|
# ------------------------------------------------------------------
|
|
# 内部工具
|
|
# ------------------------------------------------------------------
|
|
|
|
def _get_deleted_doc(self, doc_id: str, user: User) -> Document:
|
|
doc = self._session.get(Document, doc_id)
|
|
if doc is None or doc.user_id != user.id or doc.status != "DELETED":
|
|
raise NotFoundError("回收站中不存在该文档。")
|
|
return doc
|
|
|
|
def _get_deleted_category(self, cat_id: str, user: User) -> DocumentCategory:
|
|
cat = self._session.get(DocumentCategory, cat_id)
|
|
if cat is None or cat.deleted_at is None:
|
|
raise NotFoundError("回收站中不存在该目录。")
|
|
kb = self._session.get(KnowledgeBase, cat.knowledge_base_id)
|
|
if kb is None or kb.user_id != user.id:
|
|
raise NotFoundError("回收站中不存在该目录。")
|
|
return cat
|
|
|
|
def _get_deleted_kb(self, kb_id: str, user: User) -> KnowledgeBase:
|
|
kb = self._session.get(KnowledgeBase, kb_id)
|
|
if kb is None or kb.user_id != user.id or kb.status != "DELETED":
|
|
raise NotFoundError("回收站中不存在该知识库。")
|
|
return kb
|
|
|
|
def _in_category_tree(self, category_id: str, root: DocumentCategory) -> bool:
|
|
"""判断分类是否位于 root 的子树内(含自身)。"""
|
|
if category_id == root.id:
|
|
return True
|
|
c = self._session.get(DocumentCategory, category_id)
|
|
if c is None or not c.path or not root.path:
|
|
return False
|
|
return c.path.startswith(root.path)
|