From ff3e2a7273ab5c44456654ae3061c6bfd4f93625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=BB=AB?= <486494914@qq.com> Date: Wed, 2 Sep 2026 18:59:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E6=94=B6=E7=AB=99=EF=BC=8C=E8=BD=AF?= =?UTF-8?q?=E5=88=A0=E9=99=A4=EF=BC=88=E4=B8=89=E5=A4=A9=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=EF=BC=89=EF=BC=8C=E4=BB=A5=E5=8F=8A=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E9=93=BE=E6=8E=A5=E4=B8=89=E5=8D=81=E5=88=86=E9=92=9F?= =?UTF-8?q?=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...a3f8c21d94b7_add_deleted_at_recycle_bin.py | 35 ++ backend/app/api/knowledge_bases.py | 7 +- backend/app/api/recycle_bin.py | 87 +++++ backend/app/main.py | 29 +- backend/app/models/document.py | 1 + backend/app/models/document_category.py | 1 + backend/app/models/knowledge_base.py | 1 + backend/app/services/doc_service.py | 3 + backend/app/services/kb_public_service.py | 7 +- backend/app/services/kb_service.py | 2 +- backend/app/services/recycle_bin_service.py | 338 ++++++++++++++++++ backend/tests/conftest.py | 4 + backend/tests/test_link_expiry.py | 112 ++++-- frontend/src/layouts/DefaultLayout.vue | 5 + frontend/src/router/index.ts | 5 + frontend/src/views/RecycleBin.vue | 198 ++++++++++ 16 files changed, 797 insertions(+), 38 deletions(-) create mode 100644 backend/alembic/versions/a3f8c21d94b7_add_deleted_at_recycle_bin.py create mode 100644 backend/app/api/recycle_bin.py create mode 100644 backend/app/services/recycle_bin_service.py create mode 100644 frontend/src/views/RecycleBin.vue diff --git a/backend/alembic/versions/a3f8c21d94b7_add_deleted_at_recycle_bin.py b/backend/alembic/versions/a3f8c21d94b7_add_deleted_at_recycle_bin.py new file mode 100644 index 0000000..329cb1d --- /dev/null +++ b/backend/alembic/versions/a3f8c21d94b7_add_deleted_at_recycle_bin.py @@ -0,0 +1,35 @@ +"""add_deleted_at_recycle_bin + +Revision ID: a3f8c21d94b7 +Revises: 5bb03575e2e7 +Create Date: 2026-09-02 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a3f8c21d94b7' +down_revision: Union[str, None] = '5bb03575e2e7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('documents', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间')) + op.add_column('document_categories', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间')) + op.add_column('knowledge_bases', sa.Column('deleted_at', sa.String(length=32), nullable=True, comment='进入回收站时间')) + op.create_index('ix_documents_deleted_at', 'documents', ['deleted_at']) + op.create_index('ix_document_categories_deleted_at', 'document_categories', ['deleted_at']) + op.create_index('ix_knowledge_bases_deleted_at', 'knowledge_bases', ['deleted_at']) + + +def downgrade() -> None: + op.drop_index('ix_knowledge_bases_deleted_at', table_name='knowledge_bases') + op.drop_index('ix_document_categories_deleted_at', table_name='document_categories') + op.drop_index('ix_documents_deleted_at', table_name='documents') + op.drop_column('knowledge_bases', 'deleted_at') + op.drop_column('document_categories', 'deleted_at') + op.drop_column('documents', 'deleted_at') diff --git a/backend/app/api/knowledge_bases.py b/backend/app/api/knowledge_bases.py index d239017..9aa836e 100644 --- a/backend/app/api/knowledge_bases.py +++ b/backend/app/api/knowledge_bases.py @@ -87,9 +87,10 @@ def regenerate_token( ) -> KbTokenResponse: svc = KnowledgeBaseService(db) kb, token = svc.regenerate_token(kb_id, user) - # 重新生成 = 全新链接,有效期重置为长期 - svc.set_expiry(kb_id, user, None) - return KbTokenResponse(token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint) + return KbTokenResponse( + token=token, ai_url=f"/k/{token}", token_hint=kb.token_hint, + expires_at=kb.token_expires_at, is_expired=False, + ) @router.post("/{kb_id}/enable", response_model=KbResponse) diff --git a/backend/app/api/recycle_bin.py b/backend/app/api/recycle_bin.py new file mode 100644 index 0000000..8db165d --- /dev/null +++ b/backend/app/api/recycle_bin.py @@ -0,0 +1,87 @@ +"""回收站 API 路由。""" + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user, get_db +from app.models.user import User +from app.services.recycle_bin_service import RecycleBinService + +router = APIRouter(prefix="/recycle-bin", tags=["recycle-bin"]) + + +@router.get("") +def list_recycle_bin( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + """回收站内容列表。""" + return RecycleBinService(db).list_items(user) + + +@router.post("/documents/{doc_id}/restore", status_code=204) +def restore_document( + doc_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + RecycleBinService(db).restore_document(doc_id, user) + + +@router.delete("/documents/{doc_id}", status_code=204) +def purge_document( + doc_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + """彻底删除单个文档(文件+记录)。""" + RecycleBinService(db).purge_document(doc_id, user) + + +@router.post("/categories/{cat_id}/restore", status_code=204) +def restore_category( + cat_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + """恢复目录(连同同批删除的子目录与文档)。""" + RecycleBinService(db).restore_category(cat_id, user) + + +@router.delete("/categories/{cat_id}", status_code=204) +def purge_category( + cat_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + """彻底删除目录(连同同批内容)。""" + RecycleBinService(db).purge_category(cat_id, user) + + +@router.post("/knowledge-bases/{kb_id}/restore", status_code=204) +def restore_kb( + kb_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + RecycleBinService(db).restore_kb(kb_id, user) + + +@router.delete("/knowledge-bases/{kb_id}", status_code=204) +def purge_kb( + kb_id: str, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> None: + """彻底删除知识库(全部文件+记录)。""" + RecycleBinService(db).purge_kb(kb_id, user) + + +@router.post("/purge-expired") +def purge_expired( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict: + """手动触发:彻底清理所有已过保留期的内容。""" + count = RecycleBinService(db).purge_expired() + return {"purged": count} diff --git a/backend/app/main.py b/backend/app/main.py index ea955f9..694a3f9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.api.categories import router as cat_router from app.api.documents import router as doc_router from app.api.health import router as health_router from app.api.knowledge_bases import router as kb_router +from app.api.recycle_bin import router as recycle_router from app.public.routes import router as public_router from app.core.config import get_settings from app.core.db import dispose_engine, get_session_factory @@ -65,16 +66,29 @@ def _cleanup_access_logs() -> int: return count -async def _periodic_log_cleanup() -> None: - """每 24 小时清理一次过期访问日志(在线程池执行同步 DB 操作)。""" +async def _periodic_cleanup() -> None: + """每小时执行一次数据清理:访问日志 + 回收站过期内容。""" import asyncio while True: - await asyncio.sleep(24 * 3600) + await asyncio.sleep(3600) try: await asyncio.to_thread(_cleanup_access_logs) except Exception: # noqa: BLE001 logger.exception("定期清理访问日志失败") + try: + await asyncio.to_thread(_purge_recycle_bin) + except Exception: # noqa: BLE001 + logger.exception("定期清理回收站失败") + + +def _purge_recycle_bin() -> int: + """彻底删除回收站中超过保留期的内容。""" + from app.services.recycle_bin_service import RecycleBinService + + factory = get_session_factory() + with factory() as session: + return RecycleBinService(session).purge_expired() @asynccontextmanager @@ -90,14 +104,18 @@ async def lifespan(app: FastAPI): # Seed:确保 free plan 存在 _seed_free_plan() - # 启动时清理过期访问日志(不阻塞启动) + # 启动时清理过期数据:访问日志 + 回收站(不阻塞启动) import asyncio - cleanup_task = asyncio.create_task(_periodic_log_cleanup()) + cleanup_task = asyncio.create_task(_periodic_cleanup()) try: await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15) except Exception: # noqa: BLE001 logger.warning("启动时清理访问日志未完成(首次部署属正常)") + try: + await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), timeout=15) + except Exception: # noqa: BLE001 + logger.warning("启动时清理回收站未完成(首次部署属正常)") yield @@ -138,6 +156,7 @@ def create_app() -> FastAPI: app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"]) app.include_router(doc_router, prefix="/api", tags=["documents"]) app.include_router(cat_router, prefix="/api", tags=["categories"]) + app.include_router(recycle_router, prefix="/api", tags=["recycle-bin"]) app.include_router(public_router, tags=["public"]) return app diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 99f1712..d3bb230 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -129,6 +129,7 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base): deleted_at: Mapped[str | None] = mapped_column( String(32), nullable=True, + index=True, comment="进入回收站时间(NULL=未删除),3 天后自动清理", ) error_code: Mapped[str | None] = mapped_column( diff --git a/backend/app/models/document_category.py b/backend/app/models/document_category.py index 991a071..1446e94 100644 --- a/backend/app/models/document_category.py +++ b/backend/app/models/document_category.py @@ -55,6 +55,7 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): deleted_at: Mapped[str | None] = mapped_column( String(32), nullable=True, + index=True, comment="进入回收站时间(NULL=未删除),3 天后自动清理", ) diff --git a/backend/app/models/knowledge_base.py b/backend/app/models/knowledge_base.py index 1c56c00..6e44518 100644 --- a/backend/app/models/knowledge_base.py +++ b/backend/app/models/knowledge_base.py @@ -68,6 +68,7 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base): deleted_at: Mapped[str | None] = mapped_column( String(32), nullable=True, + index=True, comment="进入回收站时间(NULL=未删除),3 天后自动清理", ) diff --git a/backend/app/services/doc_service.py b/backend/app/services/doc_service.py index e60d657..199a785 100644 --- a/backend/app/services/doc_service.py +++ b/backend/app/services/doc_service.py @@ -156,8 +156,11 @@ class DocumentService: 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) doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + # 配额立即回补(用户预期删除即释放空间) + self._restore_quota(user, file_size) self._session.commit() def purge_files(self, doc: Document) -> None: diff --git a/backend/app/services/kb_public_service.py b/backend/app/services/kb_public_service.py index 4faaa2f..7c2d06b 100644 --- a/backend/app/services/kb_public_service.py +++ b/backend/app/services/kb_public_service.py @@ -56,10 +56,13 @@ class KbPublicService: return datetime.now() > expires def get_category_tree(self, kb: KnowledgeBase) -> list[dict]: - """获取目录树(含文档数量)。""" + """获取目录树(含文档数量),排除已删除目录。""" 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(self._session.scalars(stmt).all()) diff --git a/backend/app/services/kb_service.py b/backend/app/services/kb_service.py index 82a3964..b7dbd03 100644 --- a/backend/app/services/kb_service.py +++ b/backend/app/services/kb_service.py @@ -1,6 +1,6 @@ """知识库服务:CRUD + Token 管理 + 默认目录树。""" -from datetime import datetime +from datetime import datetime, timedelta from app.core.errors import NotFoundError, PermissionDeniedError from app.core.logging import get_logger diff --git a/backend/app/services/recycle_bin_service.py b/backend/app/services/recycle_bin_service.py new file mode 100644 index 0000000..36f195f --- /dev/null +++ b/backend/app/services/recycle_bin_service.py @@ -0,0 +1,338 @@ +"""回收站服务:目录/文档/知识库删除后的暂存、恢复与彻底删除。 + +- 删除 = 软删除(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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b5c01fe..46e5685 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -57,6 +57,10 @@ def _isolate_db(tmp_path, monkeypatch): from app.core import session as session_module session_module._store.clear() + # 重置存储服务单例(否则上一个测试的 tmp_path 被复用) + import app.storage.local_storage as ls + monkeypatch.setattr(ls, "_instance", None) + yield engine.dispose() \ No newline at end of file diff --git a/backend/tests/test_link_expiry.py b/backend/tests/test_link_expiry.py index faa6dbf..64babd6 100644 --- a/backend/tests/test_link_expiry.py +++ b/backend/tests/test_link_expiry.py @@ -23,14 +23,14 @@ def _setup_kb(client: TestClient) -> tuple[str, str]: return kb_id, resp.json()["token"] -def test_default_permanent() -> None: +def test_default_30min() -> None: + """新建知识库链接默认 30 分钟有效期。""" with _client() as client: kb_id, token = _setup_kb(client) resp = client.get(f"/k/{token}") assert resp.status_code == 200 - # link 接口返回长期有效 resp = client.get(f"/api/knowledge-bases/{kb_id}/link") - assert resp.json()["expires_at"] is None + assert resp.json()["expires_at"] is not None assert resp.json()["is_expired"] is False @@ -106,13 +106,14 @@ def test_regenerate_resets_expiry() -> None: kb_id, old_token = _setup_kb(client) client.post(f"/api/knowledge-bases/{kb_id}/set-expiry", json={"expires_in_minutes": -1}) - # 重新生成 → 新链接长期有效 + # 重新生成 → 新链接默认 30 分钟有效期 resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token") new_token = resp.json()["token"] assert new_token != old_token assert client.get(f"/k/{new_token}").status_code == 200 resp = client.get(f"/api/knowledge-bases/{kb_id}/link") - assert resp.json()["expires_at"] is None + assert resp.json()["expires_at"] is not None + assert resp.json()["is_expired"] is False def _make_docx_bytes() -> bytes: @@ -124,8 +125,8 @@ def _make_docx_bytes() -> bytes: return buf.getvalue() -def test_doc_delete_removes_files() -> None: - """删除文档后物理文件应被清理。""" +def test_doc_delete_goes_to_recycle_bin() -> None: + """删除文档 → 进回收站,文件保留;彻底删除后文件清理。""" with _client() as client: client.post("/api/auth/register", json={ "username": "cleanup_user", "email": "cleanup@example.com", "password": "password123", @@ -138,31 +139,37 @@ def test_doc_delete_removes_files() -> None: client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files) resp = client.get(f"/api/documents?kb_id={kb_id}") - doc = resp.json()["items"][0] - storage_path = doc["storage_path"] if "storage_path" in doc else None - doc_id = doc["id"] + doc_id = resp.json()["items"][0]["id"] - # 删除文档 - resp = client.delete(f"/api/documents/{doc_id}") - assert resp.status_code == 204 - - # 验证物理文件已删除 - from app.core.config import get_settings from pathlib import Path - # 通过数据库查询 storage_path(响应里没有) + + from app.core.config import get_settings from app.core.db import get_session_factory from app.models.document import Document + factory = get_session_factory() with factory() as session: d = session.get(Document, doc_id) storage_path = d.storage_path - if storage_path: - full = Path(get_settings().storage_root_path) / storage_path - assert not full.exists(), f"文件未被清理: {full}" + + # 删除 → 进回收站,文件仍存在 + resp = client.delete(f"/api/documents/{doc_id}") + assert resp.status_code == 204 + full = Path(get_settings().storage_root_path) / storage_path + assert full.exists(), "回收站阶段文件不应被删除" + + # 回收站列表可见 + resp = client.get("/api/recycle-bin") + assert any(item["id"] == doc_id for item in resp.json()["documents"]) + + # 彻底删除 → 文件清理 + resp = client.delete(f"/api/recycle-bin/documents/{doc_id}") + assert resp.status_code == 204 + assert not full.exists(), "彻底删除后文件应被清理" -def test_kb_delete_removes_doc_files() -> None: - """删除知识库后其下所有文档文件应被清理。""" +def test_kb_delete_goes_to_recycle_bin() -> None: + """删除知识库 → 进回收站;彻底删除后文件清理。""" with _client() as client: client.post("/api/auth/register", json={ "username": "kb_cleanup_user", "email": "kbcleanup@example.com", "password": "password123", @@ -174,20 +181,71 @@ def test_kb_delete_removes_doc_files() -> None: "application/vnd.openxmlformats-officedocument.wordprocessingml.document")} client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files) + from pathlib import Path + + from app.core.config import get_settings from app.core.db import get_session_factory from app.models.document import Document - from app.core.config import get_settings - from pathlib import Path factory = get_session_factory() with factory() as session: docs = list(session.query(Document).filter_by(knowledge_base_id=kb_id).all()) paths = [d.storage_path for d in docs if d.storage_path] - # 删除知识库 + # 删除知识库 → 回收站,文件保留 resp = client.delete(f"/api/knowledge-bases/{kb_id}") assert resp.status_code == 204 - root = Path(get_settings().storage_root_path) for p in paths: - assert not (root / p).exists(), f"文件未被清理: {p}" \ No newline at end of file + assert (root / p).exists(), "回收站阶段文件不应被删除" + + # 回收站可见 + resp = client.get("/api/recycle-bin") + assert any(item["id"] == kb_id for item in resp.json()["knowledge_bases"]) + + # 彻底删除 → 文件清理 + resp = client.delete(f"/api/recycle-bin/knowledge-bases/{kb_id}") + assert resp.status_code == 204 + for p in paths: + assert not (root / p).exists(), "彻底删除后文件应被清理" + + +def test_category_delete_and_restore() -> None: + """删除目录 → 目录+子目录+文档进回收站;恢复后全部回来。""" + with _client() as client: + client.post("/api/auth/register", json={ + "username": "cat_rb_user", "email": "catrb@example.com", "password": "password123", + }) + resp = client.post("/api/knowledge-bases", json={"name": "Cat RB KB"}) + kb_id = resp.json()["id"] + + # 在"01 公司层"下创建文本文档 + cats = client.get(f"/api/knowledge-bases/{kb_id}/categories").json() + target = next(c for c in cats if c["name"] == "公司基本信息") + + client.post("/api/documents/create-text", json={ + "kb_id": kb_id, "title": "Cat Doc", "content": "hello", + "category_id": target["id"], + }) + + # 删除目录 + resp = client.delete(f"/api/knowledge-bases/{kb_id}/categories/{target['id']}") + assert resp.status_code == 204 + + # 文档不可见 + docs = client.get(f"/api/documents?kb_id={kb_id}").json() + assert docs["total"] == 0 + + # 回收站里有目录和文档 + rb = client.get("/api/recycle-bin").json() + assert any(c["name"] == "公司基本信息" for c in rb["categories"]) + assert any(d["name"] == "Cat Doc" for d in rb["documents"]) + + # 恢复目录 → 同批文档恢复 + cat_id = next(c["id"] for c in rb["categories"] if c["name"] == "公司基本信息") + resp = client.post(f"/api/recycle-bin/categories/{cat_id}/restore") + assert resp.status_code == 204 + + docs = client.get(f"/api/documents?kb_id={kb_id}").json() + assert docs["total"] == 1 + assert docs["items"][0]["title"] == "Cat Doc" \ No newline at end of file diff --git a/frontend/src/layouts/DefaultLayout.vue b/frontend/src/layouts/DefaultLayout.vue index 66cea0c..94a3397 100644 --- a/frontend/src/layouts/DefaultLayout.vue +++ b/frontend/src/layouts/DefaultLayout.vue @@ -61,6 +61,10 @@ function navigateTo(path: string) { + + + + @@ -89,6 +93,7 @@ function navigateTo(path: string) {
📊 仪表盘
📚 知识库
⚙️ 设置
+
🗑️ 回收站
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2532c8c..957d9a8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -37,6 +37,11 @@ const router = createRouter({ name: 'KbDetail', component: () => import('@/views/KbDetail.vue'), }, + { + path: 'recycle-bin', + name: 'RecycleBin', + component: () => import('@/views/RecycleBin.vue'), + }, { path: 'settings', name: 'Settings', diff --git a/frontend/src/views/RecycleBin.vue b/frontend/src/views/RecycleBin.vue new file mode 100644 index 0000000..ad4d2d8 --- /dev/null +++ b/frontend/src/views/RecycleBin.vue @@ -0,0 +1,198 @@ + + + + +