FastAPI 项目实践与 Demo

2 阅读14分钟

FastAPI 项目实践与 Demo

完整项目示例 + 参数详解 + 常见业务模式


目录

  1. API 参数传递详解
  2. 完整项目:博客 API
  3. 常见业务模式

1. API 参数传递详解

1.1 参数类型总览

来源声明方式示例 URL说明
路径参数{param}/users/42URL 路径的一部分
查询参数param=/users/?page=1URL 问号后 ?key=value
请求体Pydantic 模型POST /users/JSON 请求体
表单参数Form()POST /loginmultipart/form-dataurlencoded
文件参数UploadFilePOST /upload文件上传
HeaderHeader()任意HTTP 请求头
CookieCookie()任意Cookie
依赖注入Depends()任意可复用的参数组合

1.2 路径参数详解

路径参数是 URL 路径中的动态部分,用花括号 {} 声明:

# 基础用法
@app.get("/users/{user_id}")
async def get_user(user_id: int):
    return {"user_id": user_id}


# 多个路径参数
@app.get("/users/{user_id}/posts/{post_id}")
async def get_post(user_id: int, post_id: int):
    return {"user_id": user_id, "post_id": post_id}


# 路径参数类型转换(自动 422 校验失败)
@app.get("/items/{item_id}")
async def get_item(item_id: int):    # 传入 "abc" → 422
    ...


@app.get("/items/{item_id}")
async def get_item(item_id: str):    # 任何值都接受
    ...


# 路径参数校验
from fastapi import Path


@app.get("/items/{item_id}")
async def get_item(
    item_id: int = Path(gt=0, description="物品 ID"),
):
    ...


# 枚举路径参数
from enum import Enum


class UserRole(str, Enum):
    admin = "admin"
    user = "user"
    guest = "guest"


@app.get("/roles/{role}")
async def get_role(role: UserRole):   # 只接受 admin/user/guest
    return {"role": role}

请求示例:

curl http://localhost:8000/users/42
# → {"user_id": 42}

curl http://localhost:8000/users/abc
# → 422: {"detail": [{"type": "int_parsing", "msg": "Input should be a valid integer"}]}

curl http://localhost:8000/roles/admin
# → {"role": "admin"}

curl http://localhost:8000/roles/superadmin
# → 422: 只接受 admin/user/guest

1.3 查询参数详解

查询参数是 URL 中间号 ? 后的 key=value&key=value

# 基本查询参数
@app.get("/items/")
async def list_items(
    skip: int = 0,           # 可选,默认 0
    limit: int = 10,         # 可选,默认 10
    q: str | None = None,    # 可选,默认 None
):
    return {"skip": skip, "limit": limit, "q": q}


# 必填查询参数
@app.get("/search/")
async def search(
    keyword: str,             # 必填,没有默认值
    page: int = 1,
):
    ...


# 查询参数类型校验
from fastapi import Query


@app.get("/items/")
async def list_items(
    q: str | None = Query(default=None, max_length=50, description="搜索关键词"),
    page: int = Query(default=1, ge=1),
    size: int = Query(default=20, ge=1, le=100),
    sort: str = Query(default="id", pattern=r"^(id|title|created_at)$"),
):
    return {"q": q, "page": page, "size": size, "sort": sort}


# 布尔值查询参数
@app.get("/items/")
async def list_items(
    is_done: bool | None = None,    # ?is_done=true / ?is_done=1 / ?is_done=yes
):
    ...


# 列表查询参数
@app.get("/items/")
async def list_items(
    tags: list[str] = Query(default=[]),  # ?tags=a&tags=b&tags=c
    ids: list[int] = Query(default=[]),   # ?ids=1&ids=2&ids=3
):
    ...


# 弃用参数
@app.get("/items/")
async def list_items(
    old_param: str | None = Query(default=None, deprecated=True),
):
    ...

请求示例:

# 基础
curl "http://localhost:8000/items/?skip=0&limit=10"

# 可选参数可省略
curl "http://localhost:8000/items/"

# 必填参数不能省略
curl "http://localhost:8000/search/"       # 422,缺少 keyword

# 列表参数
curl "http://localhost:8000/items/?tags=a&tags=b"

# 布尔值多种写法
curl "http://localhost:8000/items/?is_done=true"
curl "http://localhost:8000/items/?is_done=1"
curl "http://localhost:8000/items/?is_done=yes"

1.4 请求体参数详解

请求体参数通过 Pydantic 模型声明,从 JSON 请求体自动解析:

from pydantic import BaseModel, Field


class CreateItem(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=1000)
    price: float = Field(gt=0, le=999999)
    tags: list[str] = []


@app.post("/items/")
async def create_item(item: CreateItem):   # 自动从 JSON 解析
    return item


# 请求体 + 路径参数 + 查询参数混用
@app.put("/items/{item_id}")
async def update_item(
    item_id: int,                # 路径参数
    item: CreateItem,            # 请求体(JSON)
    force: bool | None = None,   # 查询参数
):
    return {"item_id": item_id, "force": force, **item.model_dump()}


# 多个请求体参数
from pydantic import BaseModel


class Item(BaseModel):
    name: str


class User(BaseModel):
    username: str


@app.put("/combine/")
async def combine(
    item: Item,      # {"name": "foo"}
    user: User,      # {"username": "bar"}
):
    return {"item": item, "user": user}


# 单个值作为请求体
from fastapi import Body


@app.put("/items/{item_id}")
async def update_item(
    item_id: int,
    name: str = Body(embed=True),  # {"name": "new name"}
):
    ...


# 请求体字段别名
class Item(BaseModel):
    item_name: str = Field(alias="name")  # JSON 中用 "name",代码中用 item_name

    model_config = {"populate_by_name": True}

请求示例:

# 标准请求体
curl -X POST http://localhost:8000/items/ \
  -H "Content-Type: application/json" \
  -d '{"title": "My Item", "price": 9.99}'

# 请求体 + 路径参数 + 查询参数
curl -X PUT http://localhost:8000/items/42?force=true \
  -H "Content-Type: application/json" \
  -d '{"title": "Updated", "price": 19.99}'

1.5 表单参数详解

from fastapi import Form, File, UploadFile


# 登录表单
@app.post("/login/")
async def login(
    username: str = Form(),
    password: str = Form(),
):
    return {"username": username}


# 表单 + 文件混合
@app.post("/register/")
async def register(
    username: str = Form(min_length=3),
    avatar: UploadFile = File(),
):
    return {"username": username, "filename": avatar.filename}


# 表单可选参数
@app.post("/feedback/")
async def feedback(
    name: str = Form(),
    email: str | None = Form(default=None),
    message: str = Form(max_length=500),
):
    ...

请求示例:

# 表单登录
curl -X POST http://localhost:8000/login/ \
  -d "username=admin&password=123456"

# 表单 + 文件
curl -X POST http://localhost:8000/register/ \
  -F "username=john" \
  -F "avatar=@photo.jpg"

1.6 Header 和 Cookie 参数

from fastapi import Header, Cookie, Response


@app.get("/")
async def read_root(
    user_agent: str | None = Header(default=None),
    accept_language: str | None = Header(default=None),
    x_token: str | None = Header(default=None, alias="X-Token"),
):
    return {
        "user_agent": user_agent,
        "accept_language": accept_language,
        "x_token": x_token,
    }


@app.get("/cookies/")
async def read_cookies(
    session_id: str | None = Cookie(default=None),
    response: Response = None,
):
    # 读取 Cookie
    print(session_id)

    # 设置 Cookie
    response.set_cookie(key="session_id", value="abc123", max_age=3600)
    return {"message": "Cookie set"}


# 重复 Header 值
@app.get("/headers/")
async def read_headers(
    values: list[str] | None = Header(default=None),  # X-Value: a, X-Value: b
):
    return {"values": values}

请求示例:

# 自定义 Header
curl -H "X-Token: my-secret-token" http://localhost:8000/

# Cookie
curl --cookie "session_id=abc123" http://localhost:8000/cookies/

1.7 参数优先级与混用

FastAPI 按以下规则自动识别参数来源:

参数位置识别方式
路径 {param}声明在路径模板中
查询参数非路径、非 Pydantic、非特殊类型
请求体Pydantic BaseModel
表单显式 Form()
文件UploadFile / File()
Header显式 Header()
Cookie显式 Cookie()
依赖显式 Depends()
# 完整混用示例
@app.put("/users/{user_id}/items/{item_id}")
async def complex_api(
    # 路径参数
    user_id: int,
    item_id: int,

    # 查询参数
    force: bool = False,
    q: str | None = None,

    # 请求体
    data: UpdateItem,

    # Header
    x_request_id: str | None = Header(default=None),

    # Cookie
    session: str | None = Cookie(default=None),

    # 依赖注入
    db: AsyncSession = Depends(get_db),
):
    ...

请求示例:

curl -X PUT "http://localhost:8000/users/42/items/7?force=true&q=test" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: req-123" \
  --cookie "session=abc123" \
  -d '{"title": "Updated Item", "price": 29.99}'

2. 完整项目:博客 API

本节构建一个完整的博客后端,包含用户认证、文章 CRUD、评论系统。

2.1 项目结构

blog/
├── pyproject.toml
├── .env
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── config.py
│   ├── database.py
│   ├── dependencies.py
│   ├── api/
│   │   ├── __init__.py
│   │   ├── router.py
│   │   ├── auth.py
│   │   ├── posts.py
│   │   └── comments.py
│   ├── models/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   ├── post.py
│   │   └── comment.py
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── user.py
│   │   ├── post.py
│   │   └── comment.py
│   └── core/
│       ├── __init__.py
│       ├── security.py
│       └── exceptions.py

2.2 配置与数据库

app/config.py

from pydantic_settings import BaseSettings
from functools import lru_cache


class Settings(BaseSettings):
    database_url: str = "mysql+aiomysql://root:root@localhost:3306/blog_db"
    jwt_secret_key: str = "change-me-in-production"
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30
    cors_origins: str = "http://localhost:3000"

    @property
    def cors_origin_list(self) -> list[str]:
        return [o.strip() for o in self.cors_origins.split(",")]

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}


@lru_cache
def get_settings() -> Settings:
    return Settings()

app/database.py

from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from collections.abc import AsyncGenerator

from app.config import get_settings

settings = get_settings()

engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)


class Base(DeclarativeBase):
    pass


async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with async_session() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise

2.3 模型定义

app/models/user.py

from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, timezone

from app.database import Base


def _utcnow() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
    email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
    hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
    bio: Mapped[str | None] = mapped_column(String(500), nullable=True)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    created_at: Mapped[datetime] = mapped_column(default=_utcnow)
    updated_at: Mapped[datetime] = mapped_column(default=_utcnow, onupdate=_utcnow)

    posts: Mapped[list["Post"]] = relationship(back_populates="author", cascade="all, delete-orphan")
    comments: Mapped[list["Comment"]] = relationship(back_populates="author", cascade="all, delete-orphan")

app/models/post.py

from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, timezone

from app.database import Base


def _utcnow() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


class Post(Base):
    __tablename__ = "posts"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    published: Mapped[bool] = mapped_column(Boolean, default=False)
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    created_at: Mapped[datetime] = mapped_column(default=_utcnow)
    updated_at: Mapped[datetime] = mapped_column(default=_utcnow, onupdate=_utcnow)

    author: Mapped["User"] = relationship(back_populates="posts")
    comments: Mapped[list["Comment"]] = relationship(back_populates="post", cascade="all, delete-orphan")

app/models/comment.py

from sqlalchemy import String, Text, ForeignKey, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime, timezone

from app.database import Base


def _utcnow() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


class Comment(Base):
    __tablename__ = "comments"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    content: Mapped[str] = mapped_column(Text, nullable=False)
    post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"))
    author_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
    created_at: Mapped[datetime] = mapped_column(default=_utcnow)

    post: Mapped["Post"] = relationship(back_populates="comments")
    author: Mapped["User"] = relationship(back_populates="comments")

2.4 核心工具

app/core/security.py

from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta, timezone

from app.config import get_settings

settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def hash_password(password: str) -> str:
    return pwd_context.hash(password)


def verify_password(plain: str, hashed: str) -> bool:
    return pwd_context.verify(plain, hashed)


def create_access_token(data: dict) -> str:
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
    to_encode.update({"exp": expire, "type": "access"})
    return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_token(token: str) -> dict | None:
    try:
        return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
    except JWTError:
        return None

app/core/exceptions.py

from fastapi import HTTPException, status


class NotFound(HTTPException):
    def __init__(self, detail: str = "Resource not found"):
        super().__init__(status_code=status.HTTP_404_NOT_FOUND, detail=detail)


class Unauthorized(HTTPException):
    def __init__(self, detail: str = "Not authenticated"):
        super().__init__(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=detail,
            headers={"WWW-Authenticate": "Bearer"},
        )


class Forbidden(HTTPException):
    def __init__(self, detail: str = "Permission denied"):
        super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)


class Conflict(HTTPException):
    def __init__(self, detail: str = "Resource already exists"):
        super().__init__(status_code=status.HTTP_409_CONFLICT, detail=detail)

2.5 认证接口

app/api/auth.py —— 注册、登录、获取当前用户:

from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field

from app.database import get_db
from app.models.user import User
from app.core.security import hash_password, verify_password, create_access_token, decode_token
from app.core.exceptions import Unauthorized, Conflict
from app.dependencies import get_current_user

router = APIRouter(prefix="/auth", tags=["认证"])


# ─── Schemas ───────────────────────────────────────────

class RegisterRequest(BaseModel):
    username: str = Field(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_]+$")
    email: str = Field(max_length=100)
    password: str = Field(min_length=8, max_length=100)


class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    bio: str | None = None

    model_config = {"from_attributes": True}


class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"


# ─── Routes ────────────────────────────────────────────

@router.post("/register", response_model=UserResponse, status_code=201)
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
    """注册新用户"""
    # 检查用户名是否已存在
    result = await db.execute(select(User).where(User.username == data.username))
    if result.scalar_one_or_none():
        raise Conflict("Username already taken")

    # 检查邮箱是否已存在
    result = await db.execute(select(User).where(User.email == data.email))
    if result.scalar_one_or_none():
        raise Conflict("Email already registered")

    # 创建用户
    user = User(
        username=data.username,
        email=data.email,
        hashed_password=hash_password(data.password),
    )
    db.add(user)
    await db.flush()
    await db.refresh(user)
    return user


@router.post("/login", response_model=TokenResponse)
async def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: AsyncSession = Depends(get_db),
):
    """登录获取令牌"""
    # 查找用户(支持用户名或邮箱登录)
    result = await db.execute(
        select(User).where(
            (User.username == form_data.username) | (User.email == form_data.username)
        )
    )
    user = result.scalar_one_or_none()

    if not user or not verify_password(form_data.password, user.hashed_password):
        raise Unauthorized("Invalid username or password")

    if not user.is_active:
        raise Unauthorized("Account is disabled")

    token = create_access_token({"sub": str(user.id)})
    return TokenResponse(access_token=token)


@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
    """获取当前登录用户信息"""
    return current_user

接口调用示例:

# 注册
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "email": "alice@example.com", "password": "password123"}'

# 登录
curl -X POST http://localhost:8000/api/v1/auth/login \
  -d "username=alice&password=password123"

# 获取当前用户(需要 Authorization header)
curl http://localhost:8000/api/v1/auth/me \
  -H "Authorization: Bearer <token>"

2.6 文章接口

app/api/posts.py —— 文章的增删改查、分页、搜索、排序:

from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, desc, asc
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from datetime import datetime

from app.database import get_db
from app.models.post import Post
from app.models.user import User
from app.core.exceptions import NotFound, Forbidden
from app.dependencies import get_current_user, get_optional_user

router = APIRouter(prefix="/posts", tags=["文章"])


# ─── Schemas ───────────────────────────────────────────

class PostCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    content: str = Field(min_length=1)
    published: bool = False


class PostUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    content: str | None = None
    published: bool | None = None


class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    published: bool
    author_id: int
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}


class PostListResponse(BaseModel):
    items: list[PostResponse]
    total: int
    page: int
    size: int


# ─── Routes ────────────────────────────────────────────

@router.get("/", response_model=PostListResponse)
async def list_posts(
    page: int = Query(default=1, ge=1, description="页码"),
    size: int = Query(default=20, ge=1, le=100, description="每页数量"),
    search: str | None = Query(default=None, description="搜索标题"),
    sort: str = Query(default="-created_at", pattern=r"^(-?)(created_at|updated_at|title)$", description="排序:字段名或 -字段名"),
    published: bool | None = Query(default=None, description="筛选发布状态"),
    db: AsyncSession = Depends(get_db),
):
    """文章列表(分页 + 搜索 + 排序 + 筛选)"""

    # 构建查询
    query = select(Post)

    # 筛选
    if published is not None:
        query = query.where(Post.published == published)
    if search:
        query = query.where(Post.title.ilike(f"%{search}%"))

    # 排序("-" 前缀表示降序)
    sort_field_str = sort
    sort_desc = False
    if sort.startswith("-"):
        sort_desc = True
        sort_field_str = sort[1:]

    sort_column = getattr(Post, sort_field_str)
    query = query.order_by(desc(sort_column) if sort_desc else asc(sort_column))

    # 统计总数
    count_query = select(func.count()).select_from(query.subquery())
    total_result = await db.execute(count_query)
    total = total_result.scalar()

    # 分页
    query = query.offset((page - 1) * size).limit(size)
    result = await db.execute(query)
    posts = result.scalars().all()

    return PostListResponse(items=posts, total=total, page=page, size=size)


@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(
    data: PostCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """创建文章(需登录)"""
    post = Post(**data.model_dump(), author_id=current_user.id)
    db.add(post)
    await db.flush()
    await db.refresh(post)
    return post


@router.get("/{post_id}", response_model=PostResponse)
async def get_post(
    post_id: int,
    db: AsyncSession = Depends(get_db),
):
    """获取文章详情"""
    post = await db.get(Post, post_id)
    if not post:
        raise NotFound("Post not found")
    return post


@router.put("/{post_id}", response_model=PostResponse)
async def update_post(
    post_id: int,
    data: PostUpdate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """更新文章(仅作者可操作)"""
    post = await db.get(Post, post_id)
    if not post:
        raise NotFound("Post not found")
    if post.author_id != current_user.id:
        raise Forbidden("You can only edit your own posts")

    for key, value in data.model_dump(exclude_unset=True).items():
        setattr(post, key, value)
    await db.flush()
    await db.refresh(post)
    return post


@router.delete("/{post_id}", status_code=204)
async def delete_post(
    post_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """删除文章(仅作者可操作)"""
    post = await db.get(Post, post_id)
    if not post:
        raise NotFound("Post not found")
    if post.author_id != current_user.id:
        raise Forbidden("You can only delete your own posts")
    await db.delete(post)

请求示例:

# 创建文章(需登录)
curl -X POST http://localhost:8000/api/v1/posts/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{"title": "My First Post", "content": "Hello World!", "published": true}'

# 文章列表(分页 + 搜索 + 排序)
curl "http://localhost:8000/api/v1/posts/?page=1&size=10&search=Hello&sort=-created_at&published=true"

# 获取单篇文章
curl http://localhost:8000/api/v1/posts/1

# 更新文章(仅作者)
curl -X PUT http://localhost:8000/api/v1/posts/1 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{"title": "Updated Title"}'

# 删除文章(仅作者)
curl -X DELETE http://localhost:8000/api/v1/posts/1 \
  -H "Authorization: Bearer <token>"

2.7 评论接口

app/api/comments.py —— 评论的增删改查:

from fastapi import APIRouter, Depends
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, Field
from datetime import datetime

from app.database import get_db
from app.models.comment import Comment
from app.models.user import User
from app.core.exceptions import NotFound, Forbidden
from app.dependencies import get_current_user

router = APIRouter(prefix="/posts/{post_id}/comments", tags=["评论"])


# ─── Schemas ───────────────────────────────────────────

class CommentCreate(BaseModel):
    content: str = Field(min_length=1, max_length=2000)


class CommentResponse(BaseModel):
    id: int
    content: str
    post_id: int
    author_id: int
    created_at: datetime

    model_config = {"from_attributes": True}


class CommentListResponse(BaseModel):
    items: list[CommentResponse]
    total: int


# ─── Routes ────────────────────────────────────────────

@router.get("/", response_model=CommentListResponse)
async def list_comments(
    post_id: int,
    db: AsyncSession = Depends(get_db),
):
    """获取文章的所有评论"""
    query = (
        select(Comment)
        .where(Comment.post_id == post_id)
        .order_by(Comment.created_at)
    )

    count_query = select(func.count()).select_from(query.subquery())
    total_result = await db.execute(count_query)
    total = total_result.scalar()

    result = await db.execute(query)
    comments = result.scalars().all()

    return CommentListResponse(items=comments, total=total)


@router.post("/", response_model=CommentResponse, status_code=201)
async def create_comment(
    post_id: int,
    data: CommentCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """发表评论(需登录)"""
    comment = Comment(content=data.content, post_id=post_id, author_id=current_user.id)
    db.add(comment)
    await db.flush()
    await db.refresh(comment)
    return comment


@router.delete("/{comment_id}", status_code=204)
async def delete_comment(
    post_id: int,
    comment_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    """删除评论(仅评论作者或文章作者可操作)"""
    comment = await db.get(Comment, comment_id)
    if not comment:
        raise NotFound("Comment not found")

    if comment.author_id != current_user.id:
        # 检查是否为文章作者
        from app.models.post import Post
        post = await db.get(Post, post_id)
        if not post or post.author_id != current_user.id:
            raise Forbidden("You cannot delete this comment")

    await db.delete(comment)

请求示例:

# 发表评论
curl -X POST http://localhost:8000/api/v1/posts/1/comments/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{"content": "Great post!"}'

# 查看评论
curl http://localhost:8000/api/v1/posts/1/comments/

# 删除评论
curl -X DELETE http://localhost:8000/api/v1/posts/1/comments/5 \
  -H "Authorization: Bearer <token>"

2.8 路由汇总

app/api/router.py

from fastapi import APIRouter

from app.api.auth import router as auth_router
from app.api.posts import router as posts_router
from app.api.comments import router as comments_router

api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth_router)
api_router.include_router(posts_router)
api_router.include_router(comments_router)

2.9 依赖模块

app/dependencies.py

from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.ext.asyncio import AsyncSession

from app.database import get_db
from app.models.user import User
from app.core.security import decode_token
from app.core.exceptions import Unauthorized

security = HTTPBearer()


async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
    db: AsyncSession = Depends(get_db),
) -> User:
    """从 JWT 令牌获取当前用户"""
    payload = decode_token(credentials.credentials)
    if payload is None or payload.get("type") != "access":
        raise Unauthorized("Invalid token")

    user_id = int(payload.get("sub"))
    user = await db.get(User, user_id)
    if not user or not user.is_active:
        raise Unauthorized("User not found or disabled")
    return user


async def get_optional_user(
    credentials: HTTPAuthorizationCredentials | None = Depends(HTTPBearer(auto_error=False)),
    db: AsyncSession = Depends(get_db),
) -> User | None:
    """可选的用户认证(未登录返回 None)"""
    if credentials is None:
        return None
    payload = decode_token(credentials.credentials)
    if payload is None:
        return None
    user = await db.get(User, int(payload.get("sub")))
    return user

2.10 应用入口

app/main.py

from contextlib import asynccontextmanager
from collections.abc import AsyncGenerator

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.api.router import api_router
from app.config import get_settings

settings = get_settings()


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    yield


app = FastAPI(
    title="Blog API",
    description="完整的博客后端系统",
    version="1.0.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.cors_origin_list,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(api_router)


@app.get("/health")
async def health():
    return {"status": "ok"}

2.11 博客 API 接口一览

方法路径认证说明
POST/api/v1/auth/register注册
POST/api/v1/auth/login登录获取 token
GET/api/v1/auth/me获取当前用户
GET/api/v1/posts/文章列表(分页/搜索/排序/筛选)
POST/api/v1/posts/创建文章
GET/api/v1/posts/{id}文章详情
PUT/api/v1/posts/{id}更新文章
DELETE/api/v1/posts/{id}删除文章
GET/api/v1/posts/{id}/comments/评论列表
POST/api/v1/posts/{id}/comments/发表评论
DELETE/api/v1/posts/{id}/comments/{cid}删除评论
GET/health健康检查

核心知识点对应:

接口涉及知识点
注册请求体验证、密码哈希、唯一约束
登录表单参数、JWT 生成、密码验证
文章列表分页、搜索、排序、筛选、可选参数、查询参数校验
文章 CRUD路径参数、资源归属认证、HTTP 状态码
评论嵌套路由、关联查询、级联删除

3. 常见业务模式

3.1 软删除

不真正删除数据,而是标记为已删除:

from sqlalchemy import Boolean, DateTime
from datetime import datetime, timezone


class SoftDeleteMixin:
    is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
    deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)


class Post(SoftDeleteMixin, Base):
    __tablename__ = "posts"
    # ... 其他字段


# 查询时自动过滤已删除
@app.get("/posts/")
async def list_posts(db: AsyncSession = Depends(get_db)):
    query = select(Post).where(Post.is_deleted == False)
    ...


# "删除"操作改为标记
@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(post_id: int, db: AsyncSession = Depends(get_db)):
    post = await db.get(Post, post_id)
    post.is_deleted = True
    post.deleted_at = datetime.now(timezone.utc)

3.2 批量操作

from pydantic import BaseModel


class BatchDelete(BaseModel):
    ids: list[int] = Field(min_length=1, max_length=100)


@app.post("/posts/batch-delete", status_code=204)
async def batch_delete_posts(
    data: BatchDelete,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    result = await db.execute(
        delete(Post).where(
            Post.id.in_(data.ids),
            Post.author_id == current_user.id,
        )
    )
    if result.rowcount == 0:
        raise NotFound("No posts found to delete")


@app.post("/posts/batch-publish", status_code=204)
async def batch_publish(
    data: BatchDelete,  # 复用,只是 ids
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    await db.execute(
        update(Post)
        .where(Post.id.in_(data.ids), Post.author_id == current_user.id)
        .values(published=True)
    )

3.3 数据导出

import csv
import io
from fastapi.responses import StreamingResponse


@app.get("/posts/export/csv")
async def export_posts_csv(
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    result = await db.execute(
        select(Post).where(Post.author_id == current_user.id)
    )
    posts = result.scalars().all()

    output = io.StringIO()
    writer = csv.writer(output)
    writer.writerow(["ID", "Title", "Published", "Created At"])
    for post in posts:
        writer.writerow([post.id, post.title, post.published, post.created_at])

    output.seek(0)
    return StreamingResponse(
        iter([output.getvalue()]),
        media_type="text/csv",
        headers={"Content-Disposition": "attachment; filename=posts.csv"},
    )

3.4 审计日志(记录操作人 + 时间)

from sqlalchemy import String, DateTime, Text, Integer
from datetime import datetime, timezone


class AuditLog(Base):
    __tablename__ = "audit_logs"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
    action: Mapped[str] = mapped_column(String(50))       # create / update / delete
    resource: Mapped[str] = mapped_column(String(50))     # post / comment
    resource_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
    detail: Mapped[str | None] = mapped_column(Text, nullable=True)
    created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))


def log_action(
    db: AsyncSession,
    user_id: int | None,
    action: str,
    resource: str,
    resource_id: int | None = None,
    detail: str | None = None,
):
    log = AuditLog(
        user_id=user_id,
        action=action,
        resource=resource,
        resource_id=resource_id,
        detail=detail,
    )
    db.add(log)


# 使用
@router.post("/posts/", status_code=201)
async def create_post(
    data: PostCreate,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    post = Post(**data.model_dump(), author_id=current_user.id)
    db.add(post)
    await db.flush()

    log_action(db, current_user.id, "create", "post", post.id)

    await db.refresh(post)
    return post

3.5 限流(使用 SlowApi)

uv add slowapi
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)


@app.get("/posts/")
@limiter.limit("100/minute")          # 每分钟最多 100 次
async def list_posts(request: Request, db: AsyncSession = Depends(get_db)):
    ...


@app.post("/auth/login")
@limiter.limit("5/minute")            # 登录接口更严格
async def login(request: Request, ...):
    ...

3.6 缓存查询结果

from functools import lru_cache
from sqlalchemy import select


# 缓存不常变动的数据
@lru_cache(maxsize=128)
def get_category_list_sync():
    """同步函数,结果缓存 60 秒"""
    ...


# 使用 Redis 缓存(需要 redis-py)
# pip install redis
import json
import aioredis


async def get_cached_or_query(db: AsyncSession, cache_key: str, query, ttl: int = 300):
    redis = await aioredis.from_url("redis://localhost:6379")
    cached = await redis.get(cache_key)
    if cached:
        return json.loads(cached)

    result = await db.execute(query)
    data = result.scalars().all()

    # 序列化并缓存
    serialized = [{"id": item.id, "title": item.title} for item in data]
    await redis.setex(cache_key, ttl, json.dumps(serialized))
    return data

3.7 带事务的批量操作

from sqlalchemy.ext.asyncio import AsyncSession


async def transfer_post_ownership(
    db: AsyncSession,
    old_owner_id: int,
    new_owner_id: int,
):
    """将旧作者的所有文章转移给新作者(原子操作)"""
    try:
        result = await db.execute(
            select(Post).where(Post.author_id == old_owner_id)
        )
        posts = result.scalars().all()

        for post in posts:
            post.author_id = new_owner_id

        # 无需手动 commit,get_db 会自动提交
        # 如果中途出错,get_db 会自动回滚
    except Exception:
        await db.rollback()
        raise

3.8 游标分页(Keyset Pagination)

offset 分页在数据量大时性能急剧下降(OFFSET 越深越慢)。游标分页用 WHERE 条件跳过已翻过的页:

from pydantic import BaseModel, Field
from sqlalchemy import select


class CursorParams:
    def __init__(
        self,
        cursor: int | None = None,   # 上一页最后一条的 id
        limit: int = 20,
    ):
        self.cursor = cursor
        self.limit = min(limit, 100)


class CursorPage(BaseModel):
    items: list
    next_cursor: int | None = None   # 下一页的 cursor 值
    has_more: bool = False


@app.get("/posts/", response_model=CursorPage)
async def list_posts_cursor(
    pagination: CursorParams = Depends(),
    db: AsyncSession = Depends(get_db),
):
    query = select(Post).order_by(Post.id).limit(pagination.limit + 1)

    if pagination.cursor:
        query = query.where(Post.id > pagination.cursor)

    result = await db.execute(query)
    posts = result.scalars().all()

    has_more = len(posts) > pagination.limit
    if has_more:
        posts = posts[:pagination.limit]

    next_cursor = posts[-1].id if posts else None

    return CursorPage(items=posts, next_cursor=next_cursor, has_more=has_more)

请求示例:

curl "http://localhost:8000/api/v1/posts/?cursor=50&limit=20"
# → {"items": [...], "next_cursor": 70, "has_more": true}

游标分页比 offset 分页的优势:

  • 数据量越大优势越明显(O(1) vs O(n))
  • 翻页过程中插入/删除不会导致重复或遗漏
  • 缺点:不能直接跳到指定页码

3.9 发送邮件

uv add aiosmtplib
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.config import get_settings


class EmailService:
    def __init__(self):
        settings = get_settings()
        self.host = settings.smtp_host
        self.port = settings.smtp_port
        self.user = settings.smtp_user
        self.password = settings.smtp_password

    async def send(
        self,
        to: str,
        subject: str,
        body: str,
        html: str | None = None,
    ):
        msg = MIMEMultipart("alternative")
        msg["Subject"] = subject
        msg["From"] = self.user
        msg["To"] = to

        msg.attach(MIMEText(body, "plain", "utf-8"))
        if html:
            msg.attach(MIMEText(html, "html", "utf-8"))

        # 同步方式(推荐放到 BackgroundTasks 中执行)
        with smtplib.SMTP(self.host, self.port) as server:
            server.starttls()
            server.login(self.user, self.password)
            server.sendmail(self.user, [to], msg.as_string())


email_service = EmailService()


# 使用(结合后台任务)
@app.post("/auth/forgot-password")
async def forgot_password(
    email: str,
    background_tasks: BackgroundTasks,
):
    token = create_reset_token(email)
    reset_url = f"http://localhost:3000/reset-password?token={token}"

    background_tasks.add_task(
        email_service.send,
        to=email,
        subject="Reset your password",
        body=f"Click here to reset: {reset_url}",
        html=f"<a href='{reset_url}'>Reset password</a>",
    )
    return {"message": "Email sent if account exists"}

配置项:

class Settings(BaseSettings):
    smtp_host: str = "smtp.gmail.com"
    smtp_port: int = 587
    smtp_user: str = ""
    smtp_password: str = ""

3.10 预提交钩子(Pre-commit)

# 安装 pre-commit
brew install pre-commit   # macOS
pip install pre-commit    # pip

# 项目根目录创建 .pre-commit-config.yaml

.pre-commit-config.yaml

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.8.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.13.0
    hooks:
      - id: mypy
        additional_dependencies: ["sqlalchemy", "pydantic"]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - id: trailing-whitespace    # 移除行尾空格
      - id: end-of-file-fixer      # 文件末尾换行
      - id: check-yaml             # YAML 语法检查
      - id: check-json             # JSON 语法检查
      - id: check-merge-conflict   # 检查合并冲突
      - id: detect-private-key     # 检查私钥泄露
# 安装钩子
pre-commit install

# 手动运行
pre-commit run --all-files

# 跳过钩子(紧急提交)
git commit --no-verify

附录:API 设计规范速查

URL 设计

GET     /api/v1/posts              # 列表
POST    /api/v1/posts              # 创建
GET     /api/v1/posts/{id}         # 详情
PUT     /api/v1/posts/{id}         # 全量更新
PATCH   /api/v1/posts/{id}         # 部分更新
DELETE  /api/v1/posts/{id}         # 删除
GET     /api/v1/posts/{id}/comments  # 子资源列表
POST    /api/v1/posts/{id}/comments  # 创建子资源

查询参数规范

?page=1&size=20                   # 分页
&search=keyword                   # 搜索
&sort=-created_at                 # 排序(- 表示降序)
&published=true                   # 筛选
&fields=id,title                  # 字段选择
&include=author,comments          # 包含关联

状态码规范

状态码含义使用场景
200OK查询成功
201Created创建成功
204No Content删除成功
400Bad Request参数校验失败
401Unauthorized未登录或 token 无效
403Forbidden无权限操作
404Not Found资源不存在
409Conflict资源冲突(如用户名已存在)
422Unprocessable Entity请求体验证失败
429Too Many Requests限流
500Internal Server Error服务器内部错误

错误响应格式

{
    "detail": "Human readable error message"
}

Pydantic 校验失败时:

{
    "detail": [
        {
            "type": "string_too_short",
            "loc": ["body", "title"],
            "msg": "String should have at least 1 character",
            "input": ""
        }
    ]
}