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) {