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