"""回收站服务:目录/文档/知识库删除后的暂存、恢复与彻底删除。 - 删除 = 软删除(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)