From b80161972a4d950e196d6ef688046a90e35c62e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=BB=AB?= <486494914@qq.com> Date: Tue, 1 Sep 2026 20:51:46 +0800 Subject: [PATCH] 6 --- ...6818_add_category_tree_and_text_content.py | 47 + backend/app/api/categories.py | 219 +++- backend/app/api/documents.py | 30 + backend/app/core/errors.py | 14 +- backend/app/models/document.py | 30 +- backend/app/models/document_category.py | 32 +- backend/app/public/routes.py | 137 +- backend/app/repositories/doc_repo.py | 5 +- backend/app/services/doc_service.py | 86 ++ backend/app/services/kb_public_service.py | 96 +- backend/app/services/kb_service.py | 41 +- backend/tests/test_categories.py | 16 +- frontend/src/api/client.js | 11 +- frontend/src/api/client.ts | 10 +- frontend/src/views/KbDetail.vue | 342 ++++- frontend/src/views/KbDetail.vue.js | 1116 +++++++++++++---- frontend/src/views/Register.vue | 3 +- frontend/src/views/Register.vue.js | 2 +- 18 files changed, 1878 insertions(+), 359 deletions(-) create mode 100644 backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py diff --git a/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py b/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py new file mode 100644 index 0000000..ab1d355 --- /dev/null +++ b/backend/alembic/versions/4bd4c7f26818_add_category_tree_and_text_content.py @@ -0,0 +1,47 @@ +"""add_category_tree_and_text_content + +Revision ID: 4bd4c7f26818 +Revises: 4fcdb390ae18 +Create Date: 2026-09-01 18:02:12.469005 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4bd4c7f26818' +down_revision: Union[str, None] = '4fcdb390ae18' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # document_categories 新增字段 + op.add_column('document_categories', sa.Column('parent_id', sa.String(length=32), nullable=True, comment='父分类 ID(NULL = 顶层)')) + op.add_column('document_categories', sa.Column('path', sa.Text(), server_default='/', nullable=False, comment='物化路径')) + op.add_column('document_categories', sa.Column('is_folder', sa.Boolean(), server_default=sa.text('1'), nullable=False, comment='True=文件夹')) + op.create_index(op.f('ix_document_categories_parent_id'), 'document_categories', ['parent_id'], unique=False) + + # documents 新增字段 + op.add_column('documents', sa.Column('content', sa.Text(), nullable=True, comment='直接输入的文本内容')) + op.add_column('documents', sa.Column('content_format', sa.String(length=16), server_default='markdown', nullable=False, comment='内容格式')) + + # SQLite 不支持 ALTER COLUMN,用 batch mode 重建表 + with op.batch_alter_table('documents', schema=None) as batch_op: + batch_op.alter_column('storage_path', existing_type=sa.String(1024), nullable=True) + batch_op.alter_column('sha256', existing_type=sa.String(64), nullable=True) + + +def downgrade() -> None: + with op.batch_alter_table('documents', schema=None) as batch_op: + batch_op.alter_column('sha256', existing_type=sa.String(64), nullable=False) + batch_op.alter_column('storage_path', existing_type=sa.String(1024), nullable=False) + + op.drop_column('documents', 'content_format') + op.drop_column('documents', 'content') + op.drop_index(op.f('ix_document_categories_parent_id'), table_name='document_categories') + op.drop_column('document_categories', 'is_folder') + op.drop_column('document_categories', 'path') + op.drop_column('document_categories', 'parent_id') \ No newline at end of file diff --git a/backend/app/api/categories.py b/backend/app/api/categories.py index 200cc79..c43e9e2 100644 --- a/backend/app/api/categories.py +++ b/backend/app/api/categories.py @@ -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("知识库不存在。") \ No newline at end of file diff --git a/backend/app/api/documents.py b/backend/app/api/documents.py index 0f10e06..dc7e4fc 100644 --- a/backend/app/api/documents.py +++ b/backend/app/api/documents.py @@ -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(...), diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 75f1e13..c2cdd59 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -114,12 +114,24 @@ async def app_error_handler(_: Request, exc: AppError) -> JSONResponse: async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse: + # 序列化错误详情,过滤不可 JSON 序列化的对象 + errors = [] + for err in exc.errors(): + clean_err = { + "type": err.get("type", ""), + "loc": err.get("loc", []), + "msg": err.get("msg", ""), + } + if "input" in err: + clean_err["input"] = str(err["input"]) + errors.append(clean_err) + return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "code": "VALIDATION_ERROR", "message": "请求参数不正确。", - "detail": exc.errors(), + "detail": errors, }, ) diff --git a/backend/app/models/document.py b/backend/app/models/document.py index 584d969..dea1abf 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -37,12 +37,23 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base): original_filename: Mapped[str] = mapped_column( String(512), nullable=False, - comment="原始文件名 (仅展示)", + comment="原始文件名或文本标题 (仅展示)", ) - storage_path: Mapped[str] = mapped_column( + storage_path: Mapped[str | None] = mapped_column( String(1024), + nullable=True, + comment="相对于 data/ 的物理存储路径(文本内容文档为 NULL)", + ) + content: Mapped[str | None] = mapped_column( + Text, + nullable=True, + comment="直接输入的文本内容(非文件上传时使用)", + ) + content_format: Mapped[str] = mapped_column( + String(16), + default="markdown", nullable=False, - comment="相对于 data/ 的物理存储路径", + comment="内容格式:markdown / text", ) markdown_path: Mapped[str | None] = mapped_column( String(1024), @@ -51,23 +62,26 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base): ) file_size: Mapped[int] = mapped_column( Integer, + default=0, nullable=False, - comment="文件大小 (字节)", + comment="文件大小 (字节),文本内容文档为内容长度", ) mime_type: Mapped[str] = mapped_column( String(127), + default="text/markdown", nullable=False, comment="MIME 类型", ) file_ext: Mapped[str] = mapped_column( String(16), + default=".md", nullable=False, - comment="文件扩展名 (.docx/.pdf)", + comment="文件扩展名 (.docx/.pdf/.md)", ) - sha256: Mapped[str] = mapped_column( + sha256: Mapped[str | None] = mapped_column( String(64), - nullable=False, - comment="文件 SHA-256 哈希", + nullable=True, + comment="文件 SHA-256 哈希(文本内容文档为 NULL)", ) doc_token_hash: Mapped[str] = mapped_column( String(64), diff --git a/backend/app/models/document_category.py b/backend/app/models/document_category.py index ee450ae..36e6884 100644 --- a/backend/app/models/document_category.py +++ b/backend/app/models/document_category.py @@ -1,15 +1,16 @@ -"""DocumentCategory 文档分类模型。""" +"""DocumentCategory 文档分类模型(支持树形目录结构)。""" -from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy import Boolean, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): - """文档分类模型。 + """文档分类模型(树形结构)。 - 技术审查 §2.2:document_categories 表。 + 通过 parent_id 实现层级关系,path 用于高效查询子树。 + is_folder=True 表示文件夹(可包含子项),False 表示叶子分类。 """ __tablename__ = "document_categories" @@ -21,11 +22,30 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): index=True, comment="所属知识库 ID", ) + parent_id: Mapped[str | None] = mapped_column( + String(32), + ForeignKey("document_categories.id"), + nullable=True, + index=True, + comment="父分类 ID(NULL = 顶层)", + ) name: Mapped[str] = mapped_column( String(255), nullable=False, comment="分类名称", ) + path: Mapped[str] = mapped_column( + Text, + default="/", + nullable=False, + comment="物化路径,如 /01_公司层/04_岗位AI角色/", + ) + is_folder: Mapped[bool] = mapped_column( + Boolean, + default=True, + nullable=False, + comment="True=文件夹(可含子项),False=叶子分类", + ) sort_order: Mapped[int] = mapped_column( Integer, default=0, @@ -35,7 +55,9 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): # 关系 knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin") + parent = relationship("DocumentCategory", remote_side="DocumentCategory.id", lazy="selectin") + children = relationship("DocumentCategory", back_populates="parent", lazy="selectin") documents = relationship("Document", back_populates="category", lazy="selectin") def __repr__(self) -> str: - return f"" + return f"" \ No newline at end of file diff --git a/backend/app/public/routes.py b/backend/app/public/routes.py index 851d46f..27edd67 100644 --- a/backend/app/public/routes.py +++ b/backend/app/public/routes.py @@ -12,12 +12,14 @@ import json from fastapi import APIRouter, Depends, Query, Request, Response from fastapi.responses import HTMLResponse, PlainTextResponse +from fastapi import Path as PathParam from sqlalchemy.orm import Session from app.api.deps import get_db from app.core.errors import NotFoundError, RateLimitedError from app.core.rate_limit import check_rate_limit from app.core.security import decrypt_token +from app.models.document_category import DocumentCategory from app.services.access_log_service import AccessLogService from app.services.kb_public_service import KbPublicService @@ -151,10 +153,14 @@ def kb_index_json( "updated_at": doc.updated_at, }) + # 获取目录树 + category_tree = svc.get_category_tree(kb) + data = { "name": kb.name, "description": kb.description, "document_count": len(doc_list), + "categories": category_tree, "documents": doc_list, } @@ -168,15 +174,52 @@ def kb_index_json( def kb_index_html( token: str, request: Request, + category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"), page: int = Query(1, ge=1), db: Session = Depends(get_db), ) -> HTMLResponse: - """知识库首页(HTML)。""" + """知识库首页(HTML)。支持按目录过滤。""" _rate_limit(request, token) svc = KbPublicService(db) kb = svc.get_kb_by_token(token) _log_access(db, kb.id, f"/k/{token}", request, req_type="html") - docs, total = svc.list_documents(kb, page=page, page_size=50) + + # 获取目录树 + category_tree = svc.get_category_tree(kb) + + # 按分类过滤 + category_id = None + if category: + # 根据路径查找分类 ID + from sqlalchemy import select + stmt = select(DocumentCategory).where( + DocumentCategory.knowledge_base_id == kb.id, + DocumentCategory.path == category, + ) + cat = db.scalars(stmt).first() + if cat: + category_id = cat.id + + docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50) + + # 渲染目录树侧边栏 + def render_tree(nodes: list, level: int = 0) -> str: + html = "" + for node in nodes: + indent = " " * level + is_active = category == node["path"] + active_class = ' class="active"' if is_active else "" + doc_count = f' ({node["doc_count"]})' if node["doc_count"] > 0 else "" + + if node["is_folder"]: + html += f'{indent}{node["name"]}{doc_count}\n' + if node["children"]: + html += f'{indent}
    \n{render_tree(node["children"], level + 1)}{indent}
\n' + else: + html += f'{indent}{node["name"]}{doc_count}\n' + return html + + tree_html = render_tree(category_tree) doc_rows = "" for doc in docs: @@ -208,8 +251,19 @@ def kb_index_html( {_robots_meta()} {_referrer_meta()} -

{kb.name}

- {"

" + kb.description + "

" if kb.description else ""} -

文档列表

- {doc_rows if doc_rows else "

暂无文档。

"} - {pagination} -