回收站,软删除(三天自动清空),以及默认链接三十分钟失效
This commit is contained in:
@@ -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')
|
||||||
@@ -87,9 +87,10 @@ def regenerate_token(
|
|||||||
) -> KbTokenResponse:
|
) -> KbTokenResponse:
|
||||||
svc = KnowledgeBaseService(db)
|
svc = KnowledgeBaseService(db)
|
||||||
kb, token = svc.regenerate_token(kb_id, user)
|
kb, token = svc.regenerate_token(kb_id, user)
|
||||||
# 重新生成 = 全新链接,有效期重置为长期
|
return KbTokenResponse(
|
||||||
svc.set_expiry(kb_id, user, None)
|
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)
|
@router.post("/{kb_id}/enable", response_model=KbResponse)
|
||||||
|
|||||||
@@ -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}
|
||||||
+24
-5
@@ -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.documents import router as doc_router
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
from app.api.knowledge_bases import router as kb_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.public.routes import router as public_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.db import dispose_engine, get_session_factory
|
from app.core.db import dispose_engine, get_session_factory
|
||||||
@@ -65,16 +66,29 @@ def _cleanup_access_logs() -> int:
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_log_cleanup() -> None:
|
async def _periodic_cleanup() -> None:
|
||||||
"""每 24 小时清理一次过期访问日志(在线程池执行同步 DB 操作)。"""
|
"""每小时执行一次数据清理:访问日志 + 回收站过期内容。"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(24 * 3600)
|
await asyncio.sleep(3600)
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_cleanup_access_logs)
|
await asyncio.to_thread(_cleanup_access_logs)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.exception("定期清理访问日志失败")
|
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
|
@asynccontextmanager
|
||||||
@@ -90,14 +104,18 @@ async def lifespan(app: FastAPI):
|
|||||||
# Seed:确保 free plan 存在
|
# Seed:确保 free plan 存在
|
||||||
_seed_free_plan()
|
_seed_free_plan()
|
||||||
|
|
||||||
# 启动时清理过期访问日志(不阻塞启动)
|
# 启动时清理过期数据:访问日志 + 回收站(不阻塞启动)
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
cleanup_task = asyncio.create_task(_periodic_log_cleanup())
|
cleanup_task = asyncio.create_task(_periodic_cleanup())
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
|
await asyncio.wait_for(asyncio.to_thread(_cleanup_access_logs), timeout=15)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
logger.warning("启动时清理访问日志未完成(首次部署属正常)")
|
logger.warning("启动时清理访问日志未完成(首次部署属正常)")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(_purge_recycle_bin), timeout=15)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.warning("启动时清理回收站未完成(首次部署属正常)")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -138,6 +156,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
|
app.include_router(kb_router, prefix="/api", tags=["knowledge-bases"])
|
||||||
app.include_router(doc_router, prefix="/api", tags=["documents"])
|
app.include_router(doc_router, prefix="/api", tags=["documents"])
|
||||||
app.include_router(cat_router, prefix="/api", tags=["categories"])
|
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"])
|
app.include_router(public_router, tags=["public"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
deleted_at: Mapped[str | None] = mapped_column(
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
index=True,
|
||||||
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
)
|
)
|
||||||
error_code: Mapped[str | None] = mapped_column(
|
error_code: Mapped[str | None] = mapped_column(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
deleted_at: Mapped[str | None] = mapped_column(
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
index=True,
|
||||||
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class KnowledgeBase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
deleted_at: Mapped[str | None] = mapped_column(
|
deleted_at: Mapped[str | None] = mapped_column(
|
||||||
String(32),
|
String(32),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
|
index=True,
|
||||||
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
comment="进入回收站时间(NULL=未删除),3 天后自动清理",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -156,8 +156,11 @@ class DocumentService:
|
|||||||
def delete(self, doc_id: str, user: User) -> None:
|
def delete(self, doc_id: str, user: User) -> None:
|
||||||
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
|
"""删除文档 → 进回收站(3 天后自动彻底删除)。文件暂保留以支持恢复。"""
|
||||||
doc = self.get_or_404(doc_id, user)
|
doc = self.get_or_404(doc_id, user)
|
||||||
|
file_size = doc.file_size
|
||||||
self._doc_repo.delete(doc)
|
self._doc_repo.delete(doc)
|
||||||
doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
doc.deleted_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
# 配额立即回补(用户预期删除即释放空间)
|
||||||
|
self._restore_quota(user, file_size)
|
||||||
self._session.commit()
|
self._session.commit()
|
||||||
|
|
||||||
def purge_files(self, doc: Document) -> None:
|
def purge_files(self, doc: Document) -> None:
|
||||||
|
|||||||
@@ -56,10 +56,13 @@ class KbPublicService:
|
|||||||
return datetime.now() > expires
|
return datetime.now() > expires
|
||||||
|
|
||||||
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
|
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
|
||||||
"""获取目录树(含文档数量)。"""
|
"""获取目录树(含文档数量),排除已删除目录。"""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(DocumentCategory)
|
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)
|
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
||||||
)
|
)
|
||||||
all_cats = list(self._session.scalars(stmt).all())
|
all_cats = list(self._session.scalars(stmt).all())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
|
"""知识库服务:CRUD + Token 管理 + 默认目录树。"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from app.core.errors import NotFoundError, PermissionDeniedError
|
from app.core.errors import NotFoundError, PermissionDeniedError
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -57,6 +57,10 @@ def _isolate_db(tmp_path, monkeypatch):
|
|||||||
from app.core import session as session_module
|
from app.core import session as session_module
|
||||||
session_module._store.clear()
|
session_module._store.clear()
|
||||||
|
|
||||||
|
# 重置存储服务单例(否则上一个测试的 tmp_path 被复用)
|
||||||
|
import app.storage.local_storage as ls
|
||||||
|
monkeypatch.setattr(ls, "_instance", None)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
@@ -23,14 +23,14 @@ def _setup_kb(client: TestClient) -> tuple[str, str]:
|
|||||||
return kb_id, resp.json()["token"]
|
return kb_id, resp.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
def test_default_permanent() -> None:
|
def test_default_30min() -> None:
|
||||||
|
"""新建知识库链接默认 30 分钟有效期。"""
|
||||||
with _client() as client:
|
with _client() as client:
|
||||||
kb_id, token = _setup_kb(client)
|
kb_id, token = _setup_kb(client)
|
||||||
resp = client.get(f"/k/{token}")
|
resp = client.get(f"/k/{token}")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
# link 接口返回长期有效
|
|
||||||
resp = client.get(f"/api/knowledge-bases/{kb_id}/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
|
assert resp.json()["is_expired"] is False
|
||||||
|
|
||||||
|
|
||||||
@@ -106,13 +106,14 @@ def test_regenerate_resets_expiry() -> None:
|
|||||||
kb_id, old_token = _setup_kb(client)
|
kb_id, old_token = _setup_kb(client)
|
||||||
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
|
client.post(f"/api/knowledge-bases/{kb_id}/set-expiry",
|
||||||
json={"expires_in_minutes": -1})
|
json={"expires_in_minutes": -1})
|
||||||
# 重新生成 → 新链接长期有效
|
# 重新生成 → 新链接默认 30 分钟有效期
|
||||||
resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token")
|
resp = client.post(f"/api/knowledge-bases/{kb_id}/regenerate-token")
|
||||||
new_token = resp.json()["token"]
|
new_token = resp.json()["token"]
|
||||||
assert new_token != old_token
|
assert new_token != old_token
|
||||||
assert client.get(f"/k/{new_token}").status_code == 200
|
assert client.get(f"/k/{new_token}").status_code == 200
|
||||||
resp = client.get(f"/api/knowledge-bases/{kb_id}/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
|
||||||
|
|
||||||
|
|
||||||
def _make_docx_bytes() -> bytes:
|
def _make_docx_bytes() -> bytes:
|
||||||
@@ -124,8 +125,8 @@ def _make_docx_bytes() -> bytes:
|
|||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def test_doc_delete_removes_files() -> None:
|
def test_doc_delete_goes_to_recycle_bin() -> None:
|
||||||
"""删除文档后物理文件应被清理。"""
|
"""删除文档 → 进回收站,文件保留;彻底删除后文件清理。"""
|
||||||
with _client() as client:
|
with _client() as client:
|
||||||
client.post("/api/auth/register", json={
|
client.post("/api/auth/register", json={
|
||||||
"username": "cleanup_user", "email": "cleanup@example.com", "password": "password123",
|
"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)
|
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
|
||||||
|
|
||||||
resp = client.get(f"/api/documents?kb_id={kb_id}")
|
resp = client.get(f"/api/documents?kb_id={kb_id}")
|
||||||
doc = resp.json()["items"][0]
|
doc_id = resp.json()["items"][0]["id"]
|
||||||
storage_path = doc["storage_path"] if "storage_path" in doc else None
|
|
||||||
doc_id = doc["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
|
from pathlib import Path
|
||||||
# 通过数据库查询 storage_path(响应里没有)
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.core.db import get_session_factory
|
from app.core.db import get_session_factory
|
||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
with factory() as session:
|
with factory() as session:
|
||||||
d = session.get(Document, doc_id)
|
d = session.get(Document, doc_id)
|
||||||
storage_path = d.storage_path
|
storage_path = d.storage_path
|
||||||
if storage_path:
|
|
||||||
|
# 删除 → 进回收站,文件仍存在
|
||||||
|
resp = client.delete(f"/api/documents/{doc_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
full = Path(get_settings().storage_root_path) / storage_path
|
full = Path(get_settings().storage_root_path) / storage_path
|
||||||
assert not full.exists(), f"文件未被清理: {full}"
|
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:
|
with _client() as client:
|
||||||
client.post("/api/auth/register", json={
|
client.post("/api/auth/register", json={
|
||||||
"username": "kb_cleanup_user", "email": "kbcleanup@example.com", "password": "password123",
|
"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")}
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
|
||||||
client.post("/api/documents/upload", data={"kb_id": kb_id}, files=files)
|
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.core.db import get_session_factory
|
||||||
from app.models.document import Document
|
from app.models.document import Document
|
||||||
from app.core.config import get_settings
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
factory = get_session_factory()
|
factory = get_session_factory()
|
||||||
with factory() as session:
|
with factory() as session:
|
||||||
docs = list(session.query(Document).filter_by(knowledge_base_id=kb_id).all())
|
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]
|
paths = [d.storage_path for d in docs if d.storage_path]
|
||||||
|
|
||||||
# 删除知识库
|
# 删除知识库 → 回收站,文件保留
|
||||||
resp = client.delete(f"/api/knowledge-bases/{kb_id}")
|
resp = client.delete(f"/api/knowledge-bases/{kb_id}")
|
||||||
assert resp.status_code == 204
|
assert resp.status_code == 204
|
||||||
|
|
||||||
root = Path(get_settings().storage_root_path)
|
root = Path(get_settings().storage_root_path)
|
||||||
for p in paths:
|
for p in paths:
|
||||||
assert not (root / p).exists(), f"文件未被清理: {p}"
|
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"
|
||||||
@@ -61,6 +61,10 @@ function navigateTo(path: string) {
|
|||||||
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
|
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg></el-icon>
|
||||||
<template #title>设置</template>
|
<template #title>设置</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/recycle-bin">
|
||||||
|
<el-icon><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></el-icon>
|
||||||
|
<template #title>回收站</template>
|
||||||
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
|
|
||||||
@@ -89,6 +93,7 @@ function navigateTo(path: string) {
|
|||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/')">📊 仪表盘</div>
|
||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/knowledge-bases')">📚 知识库</div>
|
||||||
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')">⚙️ 设置</div>
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/settings')">⚙️ 设置</div>
|
||||||
|
<div style="padding: 12px 0; cursor: pointer; font-size: 16px" @click="navigateTo('/recycle-bin')">🗑️ 回收站</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const router = createRouter({
|
|||||||
name: 'KbDetail',
|
name: 'KbDetail',
|
||||||
component: () => import('@/views/KbDetail.vue'),
|
component: () => import('@/views/KbDetail.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'recycle-bin',
|
||||||
|
name: 'RecycleBin',
|
||||||
|
component: () => import('@/views/RecycleBin.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import apiClient from '@/api/client'
|
||||||
|
|
||||||
|
const data = ref<{
|
||||||
|
documents: any[]
|
||||||
|
categories: any[]
|
||||||
|
knowledge_bases: any[]
|
||||||
|
retention_days: number
|
||||||
|
}>({
|
||||||
|
documents: [],
|
||||||
|
categories: [],
|
||||||
|
knowledge_bases: [],
|
||||||
|
retention_days: 3,
|
||||||
|
})
|
||||||
|
const loading = ref(false)
|
||||||
|
const isMobile = ref(window.innerWidth <= 768)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load()
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
isMobile.value = window.innerWidth <= 768
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data: d } = await apiClient.get('/recycle-bin')
|
||||||
|
data.value = d
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreDoc(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/documents/${item.id}/restore`)
|
||||||
|
ElMessage.success('文档已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeDoc(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除「${item.name}」?文件与记录将不可恢复。`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/documents/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreCategory(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/categories/${item.id}/restore`)
|
||||||
|
ElMessage.success('目录及同批内容已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeCategory(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除目录「${item.name}」及其同批删除的全部内容?`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/categories/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreKb(item: any) {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/recycle-bin/knowledge-bases/${item.id}/restore`)
|
||||||
|
ElMessage.success('知识库已恢复。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeKb(item: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`彻底删除知识库「${item.name}」及其全部文档?不可恢复。`, '彻底删除', { type: 'warning' })
|
||||||
|
await apiClient.delete(`/recycle-bin/knowledge-bases/${item.id}`)
|
||||||
|
ElMessage.success('已彻底删除。')
|
||||||
|
load()
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(item: any) {
|
||||||
|
if (item.days_left <= 0) return '即将自动清理'
|
||||||
|
return `${item.days_left} 天后自动清理`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px">
|
||||||
|
<h1 style="margin: 0; font-size: 22px">🗑️ 回收站</h1>
|
||||||
|
<span style="color: #999; font-size: 13px">
|
||||||
|
删除的内容保留 {{ data.retention_days }} 天,超期自动彻底删除
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文档 -->
|
||||||
|
<el-card shadow="hover" style="margin-bottom: 16px">
|
||||||
|
<template #header><span style="font-weight: bold">📄 文档({{ data.documents.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.documents.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.documents" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
所属:{{ item.kb_name || '未知知识库' }} · {{ formatSize(item.file_size) }} ·
|
||||||
|
删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreDoc(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeDoc(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 目录 -->
|
||||||
|
<el-card shadow="hover" style="margin-bottom: 16px">
|
||||||
|
<template #header><span style="font-weight: bold">📁 目录({{ data.categories.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.categories.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.categories" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
所属:{{ item.kb_name || '未知知识库' }} · 删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreCategory(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeCategory(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 知识库 -->
|
||||||
|
<el-card shadow="hover">
|
||||||
|
<template #header><span style="font-weight: bold">📚 知识库({{ data.knowledge_bases.length }})</span></template>
|
||||||
|
<el-empty v-if="!data.knowledge_bases.length" description="暂无" :image-size="48" />
|
||||||
|
<div v-for="item in data.knowledge_bases" :key="item.id" class="rb-item">
|
||||||
|
<div class="rb-info">
|
||||||
|
<div class="rb-name">{{ item.name }}</div>
|
||||||
|
<div class="rb-meta">
|
||||||
|
删除于 {{ item.deleted_at }} ·
|
||||||
|
<span :style="{ color: item.days_left <= 1 ? '#f56c6c' : '#999' }">{{ statusText(item) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rb-actions">
|
||||||
|
<el-button size="small" type="primary" @click="restoreKb(item)">恢复</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="purgeKb(item)">彻底删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rb-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-meta {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rb-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user