AI 全栈开发实战(4):知识库与文档管理 —— CRUD API、文件上传、MinIO 集成

前言

用户系统完成后,今天实现核心业务模块------知识库与文档管理

用户登录后可以创建知识库、上传文档、管理文件。这是整个产品的数据入口,后面所有的 RAG 问答都基于这里的数据。

1. 知识库 CRUD

1.1 Pydantic Schema

python 复制代码
# backend/app/schemas/knowledge_base.py
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime


class KnowledgeBaseCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=200, description="知识库名称")
    description: Optional[str] = Field("", max_length=500, description="知识库描述")
    chunk_size: Optional[int] = Field(512, ge=128, le=2048, description="切分大小")
    chunk_overlap: Optional[int] = Field(128, ge=0, le=512, description="切分重叠")


class KnowledgeBaseUpdate(BaseModel):
    name: Optional[str] = Field(None, max_length=200)
    description: Optional[str] = Field(None, max_length=500)


class KnowledgeBaseResponse(BaseModel):
    id: str
    name: str
    description: str
    document_count: int
    created_at: datetime
    updated_at: datetime

    class Config:
        from_attributes = True


class KnowledgeBaseListResponse(BaseModel):
    items: List[KnowledgeBaseResponse]
    total: int

1.2 知识库 Service

python 复制代码
# backend/app/services/knowledge_base_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete
from uuid import UUID

from app.models.knowledge_base import KnowledgeBase
from app.models.document import Document


class KnowledgeBaseService:

    @staticmethod
    async def create(
        db: AsyncSession,
        user_id: str,
        name: str,
        description: str = "",
        chunk_size: int = 512,
        chunk_overlap: int = 128,
    ) -> KnowledgeBase:
        """创建知识库。"""
        kb = KnowledgeBase(
            user_id=UUID(user_id),
            name=name,
            description=description,
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
        )
        db.add(kb)
        await db.commit()
        await db.refresh(kb)
        return kb

    @staticmethod
    async def list_by_user(db: AsyncSession, user_id: str) -> List[KnowledgeBase]:
        """获取用户的所有知识库。"""
        result = await db.execute(
            select(KnowledgeBase)
            .where(KnowledgeBase.user_id == UUID(user_id))
            .order_by(KnowledgeBase.updated_at.desc())
        )
        return result.scalars().all()

    @staticmethod
    async def get_by_id(db: AsyncSession, kb_id: str, user_id: str) -> KnowledgeBase:
        """获取知识库详情,同时验证所有权。"""
        result = await db.execute(
            select(KnowledgeBase).where(
                KnowledgeBase.id == UUID(kb_id),
                KnowledgeBase.user_id == UUID(user_id),
            )
        )
        kb = result.scalar_one_or_none()
        if not kb:
            raise ValueError("知识库不存在")
        return kb

    @staticmethod
    async def update(db: AsyncSession, kb_id: str, user_id: str, **kwargs) -> KnowledgeBase:
        """更新知识库。"""
        kb = await KnowledgeBaseService.get_by_id(db, kb_id, user_id)
        for key, value in kwargs.items():
            if value is not None and hasattr(kb, key):
                setattr(kb, key, value)
        await db.commit()
        await db.refresh(kb)
        return kb

    @staticmethod
    async def delete(db: AsyncSession, kb_id: str, user_id: str):
        """删除知识库和关联的文档。"""
        kb = await KnowledgeBaseService.get_by_id(db, kb_id, user_id)
        await db.delete(kb)
        await db.commit()

1.3 知识库路由

python 复制代码
# backend/app/routers/knowledge_bases.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List

from app.database import get_db
from app.services.auth import require_auth
from app.models.user import User
from app.schemas.knowledge_base import (
    KnowledgeBaseCreate, KnowledgeBaseUpdate,
    KnowledgeBaseResponse, KnowledgeBaseListResponse,
)
from app.services.knowledge_base_service import KnowledgeBaseService

router = APIRouter()


@router.post("", response_model=KnowledgeBaseResponse, status_code=status.HTTP_201_CREATED)
async def create_knowledge_base(
    body: KnowledgeBaseCreate,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """创建知识库。"""
    kb = await KnowledgeBaseService.create(
        db, user_id=str(user.id), **body.model_dump()
    )
    return _to_response(kb)


@router.get("", response_model=KnowledgeBaseListResponse)
async def list_knowledge_bases(
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """获取知识库列表。"""
    items = await KnowledgeBaseService.list_by_user(db, str(user.id))
    return KnowledgeBaseListResponse(
        items=[_to_response(kb) for kb in items],
        total=len(items),
    )


@router.get("/{kb_id}", response_model=KnowledgeBaseResponse)
async def get_knowledge_base(
    kb_id: str,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """获取知识库详情。"""
    try:
        kb = await KnowledgeBaseService.get_by_id(db, kb_id, str(user.id))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    return _to_response(kb)


@router.put("/{kb_id}", response_model=KnowledgeBaseResponse)
async def update_knowledge_base(
    kb_id: str,
    body: KnowledgeBaseUpdate,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """更新知识库。"""
    try:
        kb = await KnowledgeBaseService.update(
            db, kb_id, str(user.id), **body.model_dump(exclude_none=True)
        )
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    return _to_response(kb)


@router.delete("/{kb_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_knowledge_base(
    kb_id: str,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """删除知识库。"""
    try:
        await KnowledgeBaseService.delete(db, kb_id, str(user.id))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))


def _to_response(kb: KnowledgeBase) -> KnowledgeBaseResponse:
    return KnowledgeBaseResponse(
        id=str(kb.id),
        name=kb.name,
        description=kb.description or "",
        document_count=kb.document_count or 0,
        created_at=kb.created_at,
        updated_at=kb.updated_at,
    )

2. 文档管理

2.1 MinIO 文件存储

python 复制代码
# backend/app/services/storage.py
from minio import Minio
from app.config import settings
import uuid
from pathlib import Path


class FileStorage:
    """文件存储服务(MinIO S3 兼容)。"""

    def __init__(self):
        self.client = Minio(
            settings.MINIO_ENDPOINT,
            access_key=settings.MINIO_ACCESS_KEY,
            secret_key=settings.MINIO_SECRET_KEY,
            secure=False,  # 内网 HTTP
        )
        self.bucket = settings.MINIO_BUCKET
        self._ensure_bucket()

    def _ensure_bucket(self):
        """确保 bucket 存在。"""
        if not self.client.bucket_exists(self.bucket):
            self.client.make_bucket(self.bucket)

    async def upload(self, file_data: bytes, filename: str, content_type: str) -> dict:
        """上传文件,返回存储路径和元数据。"""
        ext = Path(filename).suffix.lower()
        object_name = f"{uuid.uuid4().hex}{ext}"

        self.client.put_object(
            bucket_name=self.bucket,
            object_name=object_name,
            data=file_data,
            length=len(file_data),
            content_type=content_type,
        )

        file_url = f"{settings.MINIO_ENDPOINT}/{self.bucket}/{object_name}"
        return {
            "object_name": object_name,
            "file_url": file_url,
            "file_size": len(file_data),
        }

    async def delete(self, object_name: str):
        """删除文件。"""
        self.client.remove_object(self.bucket, object_name)

    async def get_download_url(self, object_name: str, expires: int = 3600) -> str:
        """获取临时下载链接。"""
        return self.client.presigned_get_object(
            self.bucket, object_name, expires=expires
        )


file_storage = FileStorage()

2.2 文档 Schema

python 复制代码
# backend/app/schemas/document.py
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime


class DocumentResponse(BaseModel):
    id: str
    filename: str
    file_size: int
    file_type: str
    status: str  # pending / processing / ready / failed
    chunk_count: int
    created_at: datetime

    class Config:
        from_attributes = True


class DocumentListResponse(BaseModel):
    items: List[DocumentResponse]
    total: int

2.3 文档 Service

python 复制代码
# backend/app/services/document_service.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from uuid import UUID
from typing import List
from pathlib import Path

from app.models.document import Document
from app.services.storage import file_storage


ALLOWED_EXTENSIONS = {".pdf", ".txt", ".md", ".docx"}
MAX_FILE_SIZE = 50 * 1024 * 1024  # 50MB


class DocumentService:

    @staticmethod
    async def upload(
        db: AsyncSession,
        kb_id: str,
        user_id: str,
        file_data: bytes,
        filename: str,
    ) -> Document:
        """上传文档。"""
        # 验证文件类型
        ext = Path(filename).suffix.lower()
        if ext not in ALLOWED_EXTENSIONS:
            raise ValueError(f"不支持的文件格式:{ext},仅支持 {', '.join(ALLOWED_EXTENSIONS)}")

        # 验证文件大小
        if len(file_data) > MAX_FILE_SIZE:
            raise ValueError(f"文件大小不能超过 50MB")

        # 上传到 MinIO
        result = await file_storage.upload(file_data, filename, _content_type(ext))

        # 创建数据库记录
        doc = Document(
            knowledge_base_id=UUID(kb_id),
            filename=filename,
            file_path=result["object_name"],
            file_size=result["file_size"],
            file_type=ext.lstrip("."),
            status="pending",
        )
        db.add(doc)
        await db.commit()
        await db.refresh(doc)

        # 更新知识库文档计数
        await db.execute(
            update(KnowledgeBase)
            .where(KnowledgeBase.id == UUID(kb_id))
            .values(document_count=KnowledgeBase.document_count + 1)
        )
        await db.commit()

        return doc

    @staticmethod
    async def list_by_kb(db: AsyncSession, kb_id: str, user_id: str) -> List[Document]:
        """获取知识库的文档列表。"""
        # 先验证知识库所有权
        from app.services.knowledge_base_service import KnowledgeBaseService
        await KnowledgeBaseService.get_by_id(db, kb_id, user_id)

        result = await db.execute(
            select(Document)
            .where(Document.knowledge_base_id == UUID(kb_id))
            .order_by(Document.created_at.desc())
        )
        return result.scalars().all()

    @staticmethod
    async def delete(db: AsyncSession, doc_id: str, kb_id: str, user_id: str):
        """删除文档。"""
        # 验证所有权
        from app.services.knowledge_base_service import KnowledgeBaseService
        await KnowledgeBaseService.get_by_id(db, kb_id, user_id)

        result = await db.execute(
            select(Document).where(
                Document.id == UUID(doc_id),
                Document.knowledge_base_id == UUID(kb_id),
            )
        )
        doc = result.scalar_one_or_none()
        if not doc:
            raise ValueError("文档不存在")

        # 删除 MinIO 文件
        await file_storage.delete(doc.file_path)

        # 删除数据库记录
        await db.delete(doc)
        await db.commit()

        # 更新计数
        await db.execute(
            update(KnowledgeBase)
            .where(KnowledgeBase.id == UUID(kb_id))
            .values(document_count=KnowledgeBase.document_count - 1)
        )
        await db.commit()


def _content_type(ext: str) -> str:
    return {
        ".pdf": "application/pdf",
        ".txt": "text/plain",
        ".md": "text/markdown",
        ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    }.get(ext, "application/octet-stream")

2.4 文档路由

python 复制代码
# backend/app/routers/documents.py
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.services.auth import require_auth
from app.models.user import User
from app.schemas.document import DocumentResponse, DocumentListResponse
from app.services.document_service import DocumentService

router = APIRouter()


@router.post("/{kb_id}/documents", response_model=DocumentResponse, status_code=201)
async def upload_document(
    kb_id: str,
    file: UploadFile = File(...),
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """上传文档到知识库。"""
    file_data = await file.read()
    try:
        doc = await DocumentService.upload(
            db, kb_id, str(user.id), file_data, file.filename
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))

    return _doc_to_response(doc)


@router.get("/{kb_id}/documents", response_model=DocumentListResponse)
async def list_documents(
    kb_id: str,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """获取文档列表。"""
    try:
        docs = await DocumentService.list_by_kb(db, kb_id, str(user.id))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))

    return DocumentListResponse(
        items=[_doc_to_response(d) for d in docs],
        total=len(docs),
    )


@router.delete("/{kb_id}/documents/{doc_id}", status_code=204)
async def delete_document(
    kb_id: str,
    doc_id: str,
    user: User = Depends(require_auth),
    db: AsyncSession = Depends(get_db),
):
    """删除文档。"""
    try:
        await DocumentService.delete(db, doc_id, kb_id, str(user.id))
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))


def _doc_to_response(doc) -> DocumentResponse:
    return DocumentResponse(
        id=str(doc.id),
        filename=doc.filename,
        file_size=doc.file_size,
        file_type=doc.file_type,
        status=doc.status,
        chunk_count=doc.chunk_count,
        created_at=doc.created_at,
    )

3. 注册路由

python 复制代码
# backend/app/main.py(更新)
from app.routers import auth, knowledge_bases, documents

app.include_router(auth.router, prefix="/api/auth", tags=["Auth"])
app.include_router(knowledge_bases.router, prefix="/api/knowledge-bases", tags=["Knowledge Bases"])
app.include_router(documents.router, prefix="/api/knowledge-bases", tags=["Documents"])

4. 前端页面

4.1 API 层

typescript 复制代码
// frontend/src/api/knowledgeBase.ts
import api from "@/lib/api";

export interface KnowledgeBase {
  id: string;
  name: string;
  description: string;
  document_count: number;
  created_at: string;
}

export interface Document {
  id: string;
  filename: string;
  file_size: number;
  file_type: string;
  status: string;
  chunk_count: number;
  created_at: string;
}

export async function listKnowledgeBases() {
  const { data } = await api.get("/knowledge-bases");
  return data as { items: KnowledgeBase[]; total: number };
}

export async function createKnowledgeBase(body: {
  name: string;
  description?: string;
}) {
  const { data } = await api.post("/knowledge-bases", body);
  return data as KnowledgeBase;
}

export async function deleteKnowledgeBase(id: string) {
  await api.delete(`/knowledge-bases/${id}`);
}

export async function listDocuments(kbId: string) {
  const { data } = await api.get(`/knowledge-bases/${kbId}/documents`);
  return data as { items: Document[]; total: number };
}

export async function uploadDocument(kbId: string, file: File) {
  const form = new FormData();
  form.append("file", file);
  const { data } = await api.post(`/knowledge-bases/${kbId}/documents`, form);
  return data as Document;
}

export async function deleteDocument(kbId: string, docId: string) {
  await api.delete(`/knowledge-bases/${kbId}/documents/${docId}`);
}

4.2 仪表盘页面

typescript 复制代码
// frontend/src/pages/Dashboard.tsx
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
import {
  listKnowledgeBases, createKnowledgeBase,
  deleteKnowledgeBase, KnowledgeBase,
} from "@/api/knowledgeBase";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

export default function Dashboard() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();
  const [kbs, setKbs] = useState<KnowledgeBase[]>([]);
  const [loading, setLoading] = useState(true);
  const [open, setOpen] = useState(false);
  const [name, setName] = useState("");
  const [desc, setDesc] = useState("");

  const load = async () => {
    setLoading(true);
    const res = await listKnowledgeBases();
    setKbs(res.items);
    setLoading(false);
  };

  useEffect(() => { load(); }, []);

  const handleCreate = async () => {
    if (!name.trim()) return;
    await createKnowledgeBase({ name, description: desc });
    setOpen(false);
    setName("");
    setDesc("");
    load();
  };

  const handleDelete = async (id: string) => {
    if (!confirm("确定删除此知识库?文档也会被删除。")) return;
    await deleteKnowledgeBase(id);
    load();
  };

  const formatSize = (bytes: number) => {
    if (bytes < 1024) return bytes + " B";
    return (bytes / 1024 / 1024).toFixed(1) + " MB";
  };

  return (
    <div className="max-w-6xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-2xl font-bold">我的知识库</h1>
          <p className="text-gray-500 text-sm mt-1">欢迎回来,{user?.nickname}</p>
        </div>
        <div className="flex gap-3">
          <Dialog open={open} onOpenChange={setOpen}>
            <DialogTrigger asChild>
              <Button>新建知识库</Button>
            </DialogTrigger>
            <DialogContent>
              <DialogHeader>
                <DialogTitle>新建知识库</DialogTitle>
              </DialogHeader>
              <div className="space-y-4 pt-4">
                <Input placeholder="名称" value={name}
                  onChange={(e) => setName(e.target.value)} />
                <Textarea placeholder="描述(可选)" value={desc}
                  onChange={(e) => setDesc(e.target.value)} />
                <Button onClick={handleCreate} className="w-full">创建</Button>
              </div>
            </DialogContent>
          </Dialog>
          <Button variant="outline" onClick={logout}>退出</Button>
        </div>
      </div>

      {loading ? (
        <div className="text-center py-20 text-gray-400">加载中...</div>
      ) : kbs.length === 0 ? (
        <div className="text-center py-20">
          <div className="text-5xl mb-4">📚</div>
          <h3 className="text-lg font-medium text-gray-600">还没有知识库</h3>
          <p className="text-gray-400 mt-1">创建一个知识库,开始上传文档</p>
          <Button className="mt-4" onClick={() => setOpen(true)}>新建知识库</Button>
        </div>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {kbs.map((kb) => (
            <Card key={kb.id} className="cursor-pointer hover:shadow-md transition"
              onClick={() => navigate(`/knowledge-bases/${kb.id}`)}>
              <CardHeader className="pb-2">
                <CardTitle className="text-lg">{kb.name}</CardTitle>
              </CardHeader>
              <CardContent>
                <p className="text-sm text-gray-500 line-clamp-2 mb-3">
                  {kb.description || "暂无描述"}
                </p>
                <div className="flex items-center justify-between text-xs text-gray-400">
                  <span>{kb.document_count} 个文档</span>
                  <Button variant="ghost" size="sm" onClick={(e) => {
                    e.stopPropagation();
                    handleDelete(kb.id);
                  }}>删除</Button>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}

4.3 知识库详情页(文件管理)

typescript 复制代码
// frontend/src/pages/KnowledgeBaseDetail.tsx
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
  listDocuments, uploadDocument,
  deleteDocument, Document,
} from "@/api/knowledgeBase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";

const STATUS_MAP: Record<string, string> = {
  pending: "等待处理",
  processing: "处理中",
  ready: "已完成",
  failed: "处理失败",
};

const STATUS_CLASS: Record<string, string> = {
  pending: "bg-yellow-100 text-yellow-700",
  processing: "bg-blue-100 text-blue-700",
  ready: "bg-green-100 text-green-700",
  failed: "bg-red-100 text-red-700",
};

export default function KnowledgeBaseDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [docs, setDocs] = useState<Document[]>([]);
  const [loading, setLoading] = useState(true);
  const [uploading, setUploading] = useState(false);
  const fileRef = useRef<HTMLInputElement>(null);

  const load = async () => {
    if (!id) return;
    setLoading(true);
    const res = await listDocuments(id);
    setDocs(res.items);
    setLoading(false);
  };

  useEffect(() => { load(); }, [id]);

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || !files.length || !id) return;
    setUploading(true);
    for (const file of Array.from(files)) {
      try {
        await uploadDocument(id, file);
      } catch (err) {
        console.error("Upload failed:", file.name, err);
      }
    }
    setUploading(false);
    load();
    if (fileRef.current) fileRef.current.value = "";
  };

  const handleDelete = async (docId: string) => {
    if (!id) return;
    if (!confirm("确定删除此文档?")) return;
    await deleteDocument(id, docId);
    load();
  };

  const 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";
  };

  return (
    <div className="max-w-4xl mx-auto px-4 py-8">
      <div className="flex items-center justify-between mb-6">
        <div>
          <button onClick={() => navigate("/dashboard")}
            className="text-sm text-gray-400 hover:text-gray-600 mb-1 block">
            ← 返回知识库列表
          </button>
          <h1 className="text-2xl font-bold">文档管理</h1>
        </div>
        <div className="flex gap-3">
          <Button variant="outline" onClick={() => navigate(`/chat?kb=${id}`)}>
            💬 开始问答
          </Button>
          <Button disabled={uploading} onClick={() => fileRef.current?.click()}>
            {uploading ? "上传中..." : "上传文档"}
          </Button>
          <input type="file" ref={fileRef} className="hidden"
            multiple accept=".pdf,.txt,.md,.docx" onChange={handleUpload} />
        </div>
      </div>

      {loading ? (
        <div className="text-center py-20 text-gray-400">加载中...</div>
      ) : docs.length === 0 ? (
        <div className="text-center py-20 border-2 border-dashed rounded-xl">
          <div className="text-5xl mb-4">📄</div>
          <h3 className="text-lg font-medium text-gray-600">还没有文档</h3>
          <p className="text-gray-400 mt-1">上传 PDF、TXT、MD 或 DOCX 文件</p>
          <Button className="mt-4" onClick={() => fileRef.current?.click()}>
            上传第一个文档
          </Button>
        </div>
      ) : (
        <div className="space-y-2">
          {docs.map((doc) => (
            <Card key={doc.id}>
              <CardContent className="flex items-center justify-between py-3 px-4">
                <div className="flex items-center gap-3">
                  <span className="text-xl">
                    {doc.file_type === "pdf" ? "📕" : doc.file_type === "md" ? "📝" : "📄"}
                  </span>
                  <div>
                    <p className="text-sm font-medium">{doc.filename}</p>
                    <p className="text-xs text-gray-400">
                      {formatSize(doc.file_size)} · {doc.chunk_count} 个片段
                    </p>
                  </div>
                </div>
                <div className="flex items-center gap-2">
                  <span className={`text-xs px-2 py-1 rounded-full ${STATUS_CLASS[doc.status]}`}>
                    {STATUS_MAP[doc.status]}
                  </span>
                  <Button variant="ghost" size="sm"
                    onClick={() => handleDelete(doc.id)}>删除</Button>
                </div>
              </CardContent>
            </Card>
          ))}
        </div>
      )}
    </div>
  );
}

5. 路由注册

typescript 复制代码
// frontend/src/App.tsx(更新)
import KnowledgeBaseDetail from "./pages/KnowledgeBaseDetail";

// 在 ProtectedRoute 内添加
<Route path="/knowledge-bases/:id" element={<KnowledgeBaseDetail />} />

6. 验证

bash 复制代码
# 1. 创建知识库
curl -X POST http://localhost:8000/api/knowledge-bases \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"我的技术文档","description":"存储技术相关文档"}'

# 响应
{"id":"uuid","name":"我的技术文档","document_count":0,...}

# 2. 上传文档
curl -X POST http://localhost:8000/api/knowledge-bases/<kb_id>/documents \
  -H "Authorization: Bearer <token>" \
  -F "file=@/path/to/document.pdf"

# 3. 查看文档列表
curl http://localhost:8000/api/knowledge-bases/<kb_id>/documents \
  -H "Authorization: Bearer <token>"

# 4. 删除文档
curl -X DELETE http://localhost:8000/api/knowledge-bases/<kb_id>/documents/<doc_id> \
  -H "Authorization: Bearer <token>"

7. 数据流总结

bash 复制代码
用户操作              API                           后端服务             存储
──────────────────────────────────────────────────────────────────────
创建知识库    → POST /api/knowledge-bases    → PostgreSQL    → users + knowledge_bases
上传文档      → POST /kb/:id/documents       → MinIO + DB    → object storage + documents
查看文档列表  → GET  /kb/:id/documents       → PostgreSQL    → documents
删除文档      → DELETE /kb/:id/documents/:id → MinIO + DB    → 删除文件 + 记录

总结

今天完成了:

组件 说明
知识库 CRUD 创建/列表/详情/更新/删除
文档上传 文件类型验证 + 大小限制 + MinIO 存储
文档列表/删除 分页显示 + 级联删除
前端仪表盘 知识库卡片网格 + 创建对话框
前端文件管理 拖拽上传 + 状态展示 + 删除
MinIO 集成 对象存储 + 预签名 URL

现在用户可以创建知识库、上传文档、管理文件了。

下一篇我们将实现文档处理 Pipeline------PDF 解析、文本切分、Embedding 向量化、存入 Qdrant,让文档变成可检索的知识。


本文是 《AI 全栈开发实战------做一个真正的产品》 系列的第 4 篇。 系列目录:

  1. ✅ 产品定义与架构设计
  2. ✅ 技术选型与项目初始化
  3. ✅ 用户系统
  4. ✅ 知识库与文档管理 ← 你在这里
  5. 📝 文档处理 Pipeline ...

本文由 Zyentor(智元界) 原创发布


本文发布于 Zyentor(智元界) ------ AI 开发者社区

相关推荐
枫叶梨花3 小时前
Dify 离线安装 OpenAI API Compatible 插件踩坑记
服务器·人工智能
踩着两条虫3 小时前
VTJ.PRO v2.4.2 私有化部署与升级实操指南
前端·人工智能·低代码·架构·数据挖掘
leo__5203 小时前
MATLAB实现UKF(无迹卡尔曼滤波)原理
人工智能·matlab
春日见3 小时前
决策规划控制面经汇总
人工智能·深度学习·算法·机器学习·自动驾驶
watersink3 小时前
LocateAnything解读
人工智能
FrameNotWork3 小时前
HarmonyOS6.1 从图像分类到目标检测的扩展实现
人工智能·harmonyos
智联物联3 小时前
办公楼转型养老公寓,边缘计算网关实现全场景智慧监护
人工智能·边缘计算·物联网解决方案·工业网关·智慧养老·数采网关·边缘盒子
库拉大叔3 小时前
工具调用效率对比实测:GPT-5.5与Gemini 3.5 Flash性能评估
java·前端·人工智能
智讯天下3 小时前
专业的高端智能照明品牌哪家好?从光学技术、系统稳定性、设计认证、服务保障四个维度看
人工智能·智能手机