6
This commit is contained in:
+199
-20
@@ -1,7 +1,8 @@
|
||||
"""文档分类 API 路由。"""
|
||||
"""文档分类 API 路由(树形目录结构)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
@@ -13,73 +14,219 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
|
||||
router = APIRouter(prefix="/knowledge-bases/{kb_id}/categories", tags=["categories"])
|
||||
|
||||
|
||||
class CategoryRequest(BaseModel):
|
||||
class CategoryCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None # NULL = 顶层
|
||||
sort_order: int = 0
|
||||
is_folder: bool = True
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
class CategoryUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None # 移动到新父节点
|
||||
sort_order: int | None = None
|
||||
is_folder: bool | None = None
|
||||
|
||||
|
||||
class CategoryTreeNode(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
path: str
|
||||
is_folder: bool
|
||||
sort_order: int
|
||||
doc_count: int = 0
|
||||
children: list["CategoryTreeNode"] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryResponse])
|
||||
def list_categories(
|
||||
CategoryTreeNode.model_rebuild()
|
||||
|
||||
|
||||
@router.get("/tree", response_model=list[CategoryTreeNode])
|
||||
def get_category_tree(
|
||||
kb_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[CategoryResponse]:
|
||||
) -> list[CategoryTreeNode]:
|
||||
"""获取完整目录树(含文档数量)。"""
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
|
||||
# 获取所有分类
|
||||
stmt = (
|
||||
select(DocumentCategory)
|
||||
.where(DocumentCategory.knowledge_base_id == kb_id)
|
||||
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
|
||||
)
|
||||
all_cats = list(db.scalars(stmt).all())
|
||||
|
||||
# 获取每个分类的文档数量
|
||||
from app.models.document import Document
|
||||
from sqlalchemy import func
|
||||
|
||||
count_stmt = (
|
||||
select(Document.category_id, func.count())
|
||||
.where(
|
||||
Document.knowledge_base_id == kb_id,
|
||||
Document.status != "DELETED",
|
||||
)
|
||||
.group_by(Document.category_id)
|
||||
)
|
||||
doc_count_map = dict(db.execute(count_stmt).all())
|
||||
|
||||
# 构建树
|
||||
cat_map = {}
|
||||
for cat in all_cats:
|
||||
cat_map[cat.id] = CategoryTreeNode(
|
||||
id=cat.id,
|
||||
name=cat.name,
|
||||
path=cat.path or "/",
|
||||
is_folder=cat.is_folder if hasattr(cat, 'is_folder') else True,
|
||||
sort_order=cat.sort_order,
|
||||
doc_count=doc_count_map.get(cat.id, 0),
|
||||
children=[],
|
||||
)
|
||||
|
||||
# 构建父子关系
|
||||
roots = []
|
||||
for cat in all_cats:
|
||||
node = cat_map[cat.id]
|
||||
if cat.parent_id and cat.parent_id in cat_map:
|
||||
cat_map[cat.parent_id].children.append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
|
||||
return roots
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryTreeNode])
|
||||
def list_categories_flat(
|
||||
kb_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""获取平铺的分类列表。"""
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = (
|
||||
select(DocumentCategory)
|
||||
.where(DocumentCategory.knowledge_base_id == kb_id)
|
||||
.order_by(DocumentCategory.sort_order)
|
||||
.order_by(DocumentCategory.path, DocumentCategory.sort_order)
|
||||
)
|
||||
cats = list(db.scalars(stmt).all())
|
||||
return [CategoryResponse.model_validate(c) for c in cats]
|
||||
return [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"path": c.path,
|
||||
"is_folder": c.is_folder if hasattr(c, 'is_folder') else True,
|
||||
"sort_order": c.sort_order,
|
||||
"parent_id": c.parent_id,
|
||||
}
|
||||
for c in cats
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryResponse, status_code=201)
|
||||
@router.post("", response_model=CategoryTreeNode, status_code=201)
|
||||
def create_category(
|
||||
kb_id: str,
|
||||
body: CategoryRequest,
|
||||
body: CategoryCreateRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CategoryResponse:
|
||||
) -> CategoryTreeNode:
|
||||
"""创建分类(支持子目录)。"""
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
|
||||
# 计算 path
|
||||
parent_path = "/"
|
||||
if body.parent_id:
|
||||
parent = db.get(DocumentCategory, body.parent_id)
|
||||
if parent is None or parent.knowledge_base_id != kb_id:
|
||||
raise NotFoundError("父分类不存在。")
|
||||
parent_path = parent.path
|
||||
|
||||
# 生成 path
|
||||
cat_id_placeholder = "new" # 创建后会更新
|
||||
path = f"{parent_path}{body.name}/"
|
||||
|
||||
cat = DocumentCategory(
|
||||
knowledge_base_id=kb_id,
|
||||
parent_id=body.parent_id,
|
||||
name=body.name,
|
||||
path=path,
|
||||
is_folder=body.is_folder,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
db.add(cat)
|
||||
db.flush()
|
||||
|
||||
# 更新 path 使用实际 ID
|
||||
cat.path = f"{parent_path}{body.name}/"
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return CategoryResponse.model_validate(cat)
|
||||
|
||||
return CategoryTreeNode(
|
||||
id=cat.id,
|
||||
name=cat.name,
|
||||
path=cat.path,
|
||||
is_folder=cat.is_folder,
|
||||
sort_order=cat.sort_order,
|
||||
doc_count=0,
|
||||
children=[],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{cat_id}", response_model=CategoryResponse)
|
||||
@router.put("/{cat_id}", response_model=CategoryTreeNode)
|
||||
def update_category(
|
||||
kb_id: str,
|
||||
cat_id: str,
|
||||
body: CategoryRequest,
|
||||
body: CategoryUpdateRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CategoryResponse:
|
||||
) -> CategoryTreeNode:
|
||||
"""更新分类(改名、移动、改类型)。"""
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
cat = db.get(DocumentCategory, cat_id)
|
||||
if cat is None or cat.knowledge_base_id != kb_id:
|
||||
raise NotFoundError("分类不存在。")
|
||||
cat.name = body.name
|
||||
cat.sort_order = body.sort_order
|
||||
|
||||
old_path = cat.path
|
||||
|
||||
if body.name is not None:
|
||||
cat.name = body.name
|
||||
if body.sort_order is not None:
|
||||
cat.sort_order = body.sort_order
|
||||
if body.is_folder is not None:
|
||||
cat.is_folder = body.is_folder
|
||||
if body.parent_id is not None:
|
||||
cat.parent_id = body.parent_id
|
||||
|
||||
# 重新计算 path
|
||||
if body.parent_id is not None or body.name is not None:
|
||||
parent_path = "/"
|
||||
if cat.parent_id:
|
||||
parent = db.get(DocumentCategory, cat.parent_id)
|
||||
if parent:
|
||||
parent_path = parent.path
|
||||
new_path = f"{parent_path}{cat.name}/"
|
||||
|
||||
# 更新所有子节点的 path
|
||||
if old_path != new_path:
|
||||
_update_children_paths(db, kb_id, old_path, new_path)
|
||||
cat.path = new_path
|
||||
|
||||
db.commit()
|
||||
db.refresh(cat)
|
||||
return CategoryResponse.model_validate(cat)
|
||||
|
||||
return CategoryTreeNode(
|
||||
id=cat.id,
|
||||
name=cat.name,
|
||||
path=cat.path,
|
||||
is_folder=cat.is_folder,
|
||||
sort_order=cat.sort_order,
|
||||
doc_count=0,
|
||||
children=[],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{cat_id}", status_code=204)
|
||||
@@ -89,16 +236,48 @@ def delete_category(
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。"""
|
||||
_check_kb_owner(kb_id, user, db)
|
||||
cat = db.get(DocumentCategory, cat_id)
|
||||
if cat is None or cat.knowledge_base_id != kb_id:
|
||||
raise NotFoundError("分类不存在。")
|
||||
|
||||
# 删除所有子分类
|
||||
if cat.path:
|
||||
stmt = select(DocumentCategory).where(
|
||||
DocumentCategory.knowledge_base_id == kb_id,
|
||||
DocumentCategory.path.startswith(cat.path),
|
||||
DocumentCategory.id != cat_id,
|
||||
)
|
||||
children = list(db.scalars(stmt).all())
|
||||
for child in children:
|
||||
db.delete(child)
|
||||
|
||||
# 关联文档的 category_id 置 NULL
|
||||
from app.models.document import Document
|
||||
|
||||
doc_stmt = select(Document).where(Document.category_id == cat_id)
|
||||
docs = list(db.scalars(doc_stmt).all())
|
||||
for doc in docs:
|
||||
doc.category_id = None
|
||||
|
||||
db.delete(cat)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _update_children_paths(db: Session, kb_id: str, old_path: str, new_path: str) -> None:
|
||||
"""更新所有子节点的 path。"""
|
||||
stmt = select(DocumentCategory).where(
|
||||
DocumentCategory.knowledge_base_id == kb_id,
|
||||
DocumentCategory.path.startswith(old_path),
|
||||
)
|
||||
children = list(db.scalars(stmt).all())
|
||||
for child in children:
|
||||
child.path = child.path.replace(old_path, new_path, 1)
|
||||
|
||||
|
||||
def _check_kb_owner(kb_id: str, user: User, db: Session) -> None:
|
||||
repo = KnowledgeBaseRepository(db)
|
||||
kb = repo.get_by_id(kb_id)
|
||||
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
|
||||
raise NotFoundError("知识库不存在。")
|
||||
raise NotFoundError("知识库不存在。")
|
||||
@@ -1,6 +1,7 @@
|
||||
"""文档 API 路由。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
@@ -16,10 +17,19 @@ from app.services.doc_service import DocumentService
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
class CreateTextRequest(BaseModel):
|
||||
kb_id: str
|
||||
title: str = Field(min_length=1, max_length=512)
|
||||
content: str = Field(min_length=1)
|
||||
content_format: str = "markdown" # markdown / text
|
||||
category_id: str | None = None
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentUploadResponse, status_code=201)
|
||||
async def upload_document(
|
||||
kb_id: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
category_id: str = Form(None),
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentUploadResponse:
|
||||
@@ -31,6 +41,7 @@ async def upload_document(
|
||||
kb_id=kb_id,
|
||||
filename=file.filename or "unknown",
|
||||
content=content,
|
||||
category_id=category_id,
|
||||
)
|
||||
return DocumentUploadResponse(
|
||||
id=doc.id,
|
||||
@@ -41,6 +52,25 @@ async def upload_document(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/create-text", response_model=DocumentResponse, status_code=201)
|
||||
def create_text_document(
|
||||
body: CreateTextRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentResponse:
|
||||
"""直接创建文本内容文档(无需上传文件)。"""
|
||||
svc = DocumentService(db)
|
||||
doc = svc.create_text(
|
||||
user=user,
|
||||
kb_id=body.kb_id,
|
||||
title=body.title,
|
||||
content=body.content,
|
||||
content_format=body.content_format,
|
||||
category_id=body.category_id,
|
||||
)
|
||||
return _to_response(doc)
|
||||
|
||||
|
||||
@router.get("", response_model=DocumentListResponse)
|
||||
def list_documents(
|
||||
kb_id: str = Query(...),
|
||||
|
||||
Reference in New Issue
Block a user