diff --git a/backend/app/api/categories.py b/backend/app/api/categories.py index c43e9e2..585b358 100644 --- a/backend/app/api/categories.py +++ b/backend/app/api/categories.py @@ -52,10 +52,13 @@ def get_category_tree( """获取完整目录树(含文档数量)。""" _check_kb_owner(kb_id, user, db) - # 获取所有分类 + # 获取所有未删除的分类 stmt = ( select(DocumentCategory) - .where(DocumentCategory.knowledge_base_id == kb_id) + .where( + DocumentCategory.knowledge_base_id == kb_id, + DocumentCategory.deleted_at.is_(None), + ) .order_by(DocumentCategory.sort_order, DocumentCategory.name) ) all_cats = list(db.scalars(stmt).all()) @@ -110,7 +113,10 @@ def list_categories_flat( stmt = ( select(DocumentCategory) - .where(DocumentCategory.knowledge_base_id == kb_id) + .where( + DocumentCategory.knowledge_base_id == kb_id, + DocumentCategory.deleted_at.is_(None), + ) .order_by(DocumentCategory.path, DocumentCategory.sort_order) ) cats = list(db.scalars(stmt).all()) @@ -236,32 +242,44 @@ def delete_category( user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> None: - """删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。""" + """删除分类 → 进回收站(含全部子分类及其下文档,3 天后自动彻底删除)。""" + from datetime import datetime + + from app.models.document import Document + _check_kb_owner(kb_id, user, db) cat = db.get(DocumentCategory, cat_id) - if cat is None or cat.knowledge_base_id != kb_id: + if cat is None or cat.knowledge_base_id != kb_id or cat.deleted_at: raise NotFoundError("分类不存在。") - # 删除所有子分类 + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 收集本分类 + 所有存活的后代分类 + target_ids = [cat_id] if cat.path: stmt = select(DocumentCategory).where( DocumentCategory.knowledge_base_id == kb_id, DocumentCategory.path.startswith(cat.path), - DocumentCategory.id != cat_id, + DocumentCategory.deleted_at.is_(None), ) - children = list(db.scalars(stmt).all()) - for child in children: - db.delete(child) + for c in db.scalars(stmt).all(): + if c.id != cat_id: + target_ids.append(c.id) - # 关联文档的 category_id 置 NULL - from app.models.document import Document + # 软删除分类树 + for cid in target_ids: + c = db.get(DocumentCategory, cid) + c.deleted_at = now - doc_stmt = select(Document).where(Document.category_id == cat_id) - docs = list(db.scalars(doc_stmt).all()) - for doc in docs: - doc.category_id = None + # 软删除目录下的文档(同一批次时间戳,便于整组恢复) + doc_stmt = select(Document).where( + Document.category_id.in_(target_ids), + Document.status != "DELETED", + ) + for doc in db.scalars(doc_stmt).all(): + doc.status = "DELETED" + doc.deleted_at = now - db.delete(cat) db.commit() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b2ea594..da8216e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,6 +41,8 @@ class Settings(BaseSettings): # --- 数据保留 --- # 访问日志保留天数,超过自动清理(0 = 永久保留,不推荐) access_log_retention_days: int = 90 + # 回收站保留天数,超期自动彻底删除(含物理文件) + recycle_bin_retention_days: int = 3 # --- CORS --- frontend_origin: str = "http://localhost:5173" diff --git a/backend/app/models/document.py b/backend/app/models/document.py index dea1abf..99f1712 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -126,6 +126,11 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, comment="状态 (PENDING/PROCESSING/READY/FAILED/DELETED)", ) + deleted_at: Mapped[str | None] = mapped_column( + String(32), + nullable=True, + comment="进入回收站时间(NULL=未删除),3 天后自动清理", + ) error_code: Mapped[str | None] = mapped_column( String(64), nullable=True, diff --git a/backend/app/models/document_category.py b/backend/app/models/document_category.py index 0511b8e..991a071 100644 --- a/backend/app/models/document_category.py +++ b/backend/app/models/document_category.py @@ -52,6 +52,11 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=False, comment="排序序号", ) + deleted_at: Mapped[str | None] = mapped_column( + String(32), + nullable=True, + comment="进入回收站时间(NULL=未删除),3 天后自动清理", + ) # 关系 knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin") diff --git a/backend/app/models/knowledge_base.py b/backend/app/models/knowledge_base.py index 989fb5a..1c56c00 100644 --- a/backend/app/models/knowledge_base.py +++ b/backend/app/models/knowledge_base.py @@ -65,6 +65,11 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base): nullable=True, comment="链接过期时间,NULL=长期有效", ) + deleted_at: Mapped[str | None] = mapped_column( + String(32), + nullable=True, + comment="进入回收站时间(NULL=未删除),3 天后自动清理", + ) # 关系 user = relationship("User", back_populates="knowledge_bases", lazy="selectin") diff --git a/backend/app/services/doc_service.py b/backend/app/services/doc_service.py index 370013a..e60d657 100644 --- a/backend/app/services/doc_service.py +++ b/backend/app/services/doc_service.py @@ -1,6 +1,7 @@ """文档服务:上传、删除、配额管理。""" import hashlib +from datetime import datetime from pathlib import Path import filetype @@ -153,16 +154,14 @@ class DocumentService: return self._doc_repo.list_by_knowledge_base(kb_id, category_id=category_id, page=page, page_size=page_size) def delete(self, doc_id: str, user: User) -> None: + """删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。""" doc = self.get_or_404(doc_id, user) - file_size = doc.file_size self._doc_repo.delete(doc) - self._delete_doc_files(doc) - # 回补配额 - self._restore_quota(user, file_size) + doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self._session.commit() - def _delete_doc_files(self, doc: Document) -> None: - """物理删除文档的原始文件与 Markdown 文件(软删后调用)。""" + def purge_files(self, doc: Document) -> None: + """物理删除文档的原始文件与 Markdown 文件(彻底删除时调用)。""" from app.storage.local_storage import get_storage storage = get_storage() diff --git a/backend/app/services/kb_service.py b/backend/app/services/kb_service.py index 226cba3..82a3964 100644 --- a/backend/app/services/kb_service.py +++ b/backend/app/services/kb_service.py @@ -1,5 +1,7 @@ """知识库服务:CRUD + Token 管理 + 默认目录树。""" +from datetime import datetime + from app.core.errors import NotFoundError, PermissionDeniedError from app.core.logging import get_logger from app.models.document_category import DocumentCategory @@ -33,6 +35,10 @@ class KnowledgeBaseService: token_encrypted=token_encrypted, token_hint=token_hint, ) + # AI 链接默认 30 分钟有效期 + from datetime import datetime, timedelta + + kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S") self._seed_default_categories(kb.id) self._session.commit() return kb, token @@ -40,7 +46,7 @@ class KnowledgeBaseService: def _seed_default_categories(self, kb_id: str) -> None: """创建默认目录树结构。""" default_tree = [ - ("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]), + ("01 公司层", ["公司基本信息", "经营理念", "四大价值"]), ("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]), ("03 部门层", ["企划", "技术", "交付", "市场"]), ("04 岗位/AI角色", []), @@ -97,39 +103,14 @@ class KnowledgeBaseService: return kb def delete(self, kb_id: str, user: User) -> None: + """删除知识库 → 进回收站(3 天后自动彻底删除)。""" kb = self.get_or_404(kb_id, user) self._kb_repo.delete(kb) - self._cleanup_kb_files(kb) + kb.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self._session.commit() - def _cleanup_kb_files(self, kb: KnowledgeBase) -> None: - """物理删除知识库下所有文档的原始文件与 Markdown 文件(软删后调用)。 - - 文件删除失败不阻塞删除流程(记录日志,可由后续清理兜底)。 - """ - from sqlalchemy import select - - from app.models.document import Document - from app.storage.local_storage import get_storage - - stmt = select(Document).where(Document.knowledge_base_id == kb.id) - docs = list(self._session.scalars(stmt).all()) - storage = get_storage() - removed = 0 - for doc in docs: - for key in (doc.storage_path, doc.markdown_path): - if not key: - continue - try: - storage.delete(key) - removed += 1 - except Exception as exc: # noqa: BLE001 - logger.warning("清理文件失败 key=%s: %s", key, exc) - if removed: - logger.info("KB %s 软删,已物理清理 %d 个文件", kb.id, removed) - def regenerate_token(self, kb_id: str, user: User) -> tuple[KnowledgeBase, str]: - """重新生成 Token。旧链接立即失效。""" + """重新生成 Token。旧链接立即失效,新链接默认 30 分钟有效期。""" kb = self.get_or_404(kb_id, user) token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair() self._kb_repo.update( @@ -138,6 +119,7 @@ class KnowledgeBaseService: token_encrypted=token_encrypted, token_hint=token_hint, ) + kb.token_expires_at = (datetime.now() + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S") self._session.commit() return kb, token @@ -149,7 +131,7 @@ class KnowledgeBaseService: def set_expiry(self, kb_id: str, user: User, expires_in_minutes: int | None) -> KnowledgeBase: """设置链接有效期。None = 长期有效;负数表示已过期(测试用)。""" - from datetime import datetime, timedelta + from datetime import timedelta kb = self.get_or_404(kb_id, user) if expires_in_minutes is None: