Files
blog-press/docs/FastAPI/FastAPIGuide.md
2025-12-10 19:56:57 +08:00

19 KiB
Raw Blame History

一、简介

FastAPI 是一个用于构建 API 的现代、快速(高性能)的 web 框架,使用 Python 并基于标准的 Python 类型提示。
  安装:

pip install fastapi
pip install uvicorn[standard]  # ASGI服务器

二、数据层

2.1 数据库ORM SQLAlchemy

2.1.1 配置与连接

# database.py - 数据库配置
from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
import os
from dotenv import load_dotenv

load_dotenv()

# 数据库配置
DATABASE_URL = f"mysql+pymysql://{os.getenv("DATABASE_URL", "root:123456@127.0.0.1:3306/test")}"

# 创建引擎
engine = create_engine(
    DATABASE_URL,
    echo=True,  # 显示 SQL 语句(开发环境)
    pool_size=20,  # 连接池大小
    max_overflow=40,  # 最大溢出连接数
    pool_pre_ping=True,  # 连接前 ping
    pool_recycle=3600,  # 连接回收时间(秒)
)

# 创建会话工厂
SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=engine,
    expire_on_commit=False,  # 提交后不使实例过期
)

# 声明基类
Base = declarative_base()

# 依赖注入:获取数据库会话
def get_db() -> Generator[Session, None, None]:
    """
    获取数据库会话
    使用 yield 确保会话正确关闭
    """
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

Warning

需要把pool_pre_ping=True打开否则会出现超过pool_recycle时间后数据库断线。

2.1.2 数据库基类

from sqlalchemy import Column, BigInteger, String, DateTime, event
from sqlalchemy.ext.declarative import declared_attr

from config.auth import context_sub
from config.database import Base
from datetime import datetime

from utils.common import camel_to_snake
from id_generator import options, generator

# https://github.com/yitter/IdGenerator/tree/master/Python
options = options.IdGeneratorOptions(worker_id=23)
idgen = generator.DefaultIdGenerator()
idgen.set_id_generator(options)


# 第二层基类包含ID
class IdBase(Base):
    __abstract__ = True

    id = Column(BigInteger, primary_key=True, index=True)

    @declared_attr
    def __tablename__(cls):
        # 自动把数据库实体类名驼峰转为数据库表名下划线
        return camel_to_snake(cls.__name__)


# 自动填充id
@event.listens_for(IdBase, 'before_insert', propagate=True)
def before_insert_listener(mapper, connection, target):
    if target.id is None:
        target.id = idgen.next_id()


# 第二层基类包含ID和审计字段
class AuditBase(IdBase):
    __abstract__ = True

    create_time = Column(DateTime, nullable=True, default=datetime.now)
    create_by = Column(String(255), nullable=True)
    update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now)
    update_by = Column(String(255), nullable=True)


@event.listens_for(AuditBase, 'before_insert', propagate=True)
def before_insert(mapper, connection, target):
    value = context_sub.get(None)
    if value is not None:
        target.create_by = value


@event.listens_for(AuditBase, 'before_update', propagate=True)
def before_update(mapper, connection, target):
    value = context_sub.get(None)
    if value is not None:
        target.update_by = value

id采用yitter雪花id。
审计字段中的create_time和update_time通过SQLAlchemy参数配置。
create_by和update_by字段为登录认证中存储的账号信息。

2.1.3 数据库模型

from sqlalchemy import (BigInteger, Boolean, Column, Integer, DECIMAL, String, LargeBinary)
from sqlalchemy.orm import relationship

from models.base import AuditBase, IdBase


class Blog(AuditBase):
    title = Column(String(255), nullable=False, comment="博客标题")
    top_value = Column(Integer, nullable=False, default=0, comment="置顶值 越大越靠前")
    is_great = Column(Boolean, nullable=False, default=False, comment="是否是精品")
    category_id = Column(BigInteger, nullable=False, comment="博客类别")
    summary = Column(String(255), nullable=False, comment="博客内容概要")
    content_id = Column(BigInteger, nullable=False, comment="博客内容")
    word_count = Column(Integer, nullable=False, default=0, comment="字数统计")
    read_duration = Column(DECIMAL(10, 2), nullable=False, default=0.00, comment="阅读时长")
    is_approved = Column(Boolean, nullable=False, default=False, comment="是否发布")

    # 分类关系(多对一)
    category = relationship(
        "BlogCategory",
        # 与BlogCategory的blogs属性建立双向关系
        back_populates="blogs",
        # 只级联保存和合并操作,不级联删除(删除博客不应删除分类
        cascade="save-update, merge",
        # 明确指定连接条件
        # 如果数据库设置了外键可以省略
        primaryjoin="foreign(Blog.category_id) == BlogCategory.id"
    )

    # 内容关系(一对一)
    content = relationship(
        "BlogContent",
        # 与BlogContent的blog属性建立双向关系
        back_populates="blog",
        # 完全级联操作:保存、合并、刷新、删除等所有操作都会级联
        cascade="all, delete-orphan",
        # 设置为False表示一对一关系返回单个对象而不是列表
        uselist=False,
        # 确保内容只有一个父博客与delete-orphan配合使用
        single_parent=True,
        # 明确指定连接条件
        primaryjoin="foreign(Blog.content_id) == BlogContent.id"
    )

    # 评论(一对多)
    comments = relationship(
        "BlogComment",
        # 与BlogComment的blog属性建立双向关系
        back_populates="blog",
        # 完全级联操作:博客删除时自动删除所有评论
        cascade="all, delete-orphan",
        # 明确指定连接条件
        primaryjoin="Blog.id == foreign(BlogComment.blog_id)"
    )


class BlogCategory(AuditBase):
    name = Column(String(45), nullable=False, comment="类别名称")

    blogs = relationship(
        "Blog",
        # 与Blog的category属性建立双向关系
        back_populates="category",
        # 完全级联操作:分类删除时自动删除所有关联的博客
        # 警告:这会级联删除分类下的所有博客,包括博客的内容、访问记录和评论
        cascade="all, delete-orphan",
        # 明确指定连接条件
        primaryjoin="BlogCategory.id == foreign(Blog.category_id)"
    )


class BlogContent(IdBase):
    content = Column(LargeBinary, nullable=False, comment="博客内容")

    blog = relationship(
        "Blog",
        # 与Blog的content属性建立双向关系
        back_populates="content",
        # 设置为False表示一对一关系
        uselist=False,
        # 明确指定连接条件
        primaryjoin="BlogContent.id == foreign(Blog.content_id)"
    )


class BlogComment(AuditBase):
    blog_id = Column(BigInteger, nullable=False, comment="博客ID")
    parent_id = Column(BigInteger, nullable=False, comment="父评论ID")
    name = Column(String(255), nullable=False, comment="评论人昵称")
    website = Column(String(255), nullable=True, comment="评论人网站")
    ip_address = Column(String(45), nullable=False, comment="评论人IP")
    user_agent = Column(String(255), nullable=False, comment="评论人浏览器信息")
    content = Column(String(255), nullable=False, comment="评论内容")
    is_approved = Column(Boolean, nullable=False, default=False, comment="是否通过")

    blog = relationship(
        "Blog",
        # 与Blog的comments属性建立双向关系
        back_populates="comments",
        # 明确指定连接条件
        primaryjoin="foreign(BlogComment.blog_id) == Blog.id"
    )

这里没有通过在数据库建立外键而是通过SQLAlchemy中的relationship来建立。
其中primaryjoin明确指定了连接条件。如果为一对一需要设置uselist=False和single_parent=True返回单个对象而不是列表。
cascade表示级联操作

# cascade 的完整可选值列表:

# 基本级联操作
"save-update"      # 保存/更新时级联
"merge"           # 合并会话时级联
"refresh-expire"  # 刷新过期对象时级联
"expunge"         # 从会话中移除时级联
"delete"          # 删除时级联
"delete-orphan"   # 成为孤儿时删除

# 快捷组合
"all"                     # 包含除 delete-orphan 外的所有操作
"all, delete-orphan"      # 包含所有操作
"none"                    # 禁用所有级联(默认)

# 其他组合
"save-update, merge"               # 常用组合
"save-update, merge, delete"       # 包含删除
"save-update, merge, refresh-expire"

2.2 数据验证pydantic

from typing import Optional

from pydantic import BaseModel, Field, field_validator
from datetime import datetime


class BlogQuery(BaseModel):
    """博客查询参数"""
    category: Optional[str] = Field(None, description="分类名称")
    title: Optional[str] = Field(None, description="标题关键词")
    year: Optional[int] = Field(None, description="发布年份", ge=2000, le=datetime.now().year)

    @field_validator('year')
    def validate_year(cls, v):
        if v is not None and v > datetime.now().year:
            raise ValueError('年份不能超过当前年份')
        return v


class BlogBase(BaseModel):
    """博客基础模型"""
    title: str = Field(..., min_length=1, max_length=255, description="博客标题")
    top_value: int = Field(default=0, ge=0, description="置顶值,越大越靠前", alias="topValue")
    is_great: bool = Field(default=False, description="是否是精品", alias="isGreat")
    category: str = Field(..., min_length=1, max_length=45, description="分类名称")
    content: Optional[str] = Field(None, description="博客内容")
    is_approved: bool = Field(default=False, description="是否已发布", alias="isApproved")

    @field_validator('title')
    def title_not_empty(cls, v):
        if not v or not v.strip():
            raise ValueError('标题不能为空')
        return v.strip()

    @field_validator('category')
    def category_not_empty(cls, v):
        if not v or not v.strip():
            raise ValueError('分类不能为空')
        return v.strip()


class BlogCreate(BlogBase):
    pass


class BlogUpdate(BlogBase):
    pass


class BlogResponse(BlogBase):
    id: int = Field(..., description="博客ID")
    summary: Optional[str] = Field(None, description="内容摘要")
    word_count: Optional[int] = Field(None, description="字数统计", alias="wordCount")
    read_duration: Optional[float] = Field(None, description="阅读时长", alias="readDuration")
    visit_count: Optional[int] = Field(0, description="访问次数", alias="visitCount")
    create_time: datetime = Field(..., description="创建时间", alias="createTime")
    update_time: datetime = Field(..., description="更新时间", alias="updateTime")

    class Config:
        from_attributes = True
        populate_by_name = True
        json_encoders = {
            # 自定义 datetime 类型的序列化格式
            datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
        }


class BlogCategoryResponse(BaseModel):
    name: str = Field(..., description="分类名称")
    count: int = Field(..., description="博客数量")

    class Config:
        from_attributes = True
        populate_by_name = True


class BlogStatsResponse(BaseModel):
    blog_count: int = Field(..., description="博客总数", alias="blogCount")
    category_count: int = Field(..., description="分类总数", alias="categoryCount")
    word_count: int = Field(..., description="总字数", alias="wordCount")


class BlogVisitResponse(BaseModel):
    ip: str = Field(..., description="IP地址")
    os: str = Field(..., description="操作系统")
    browser: str = Field(..., description="浏览器")
    uri: str = Field(..., description="访问路径")
    title: str = Field(None, description="博客标题")
    visit_time: datetime = Field(..., description="访问时间", alias="visitTime")

    class Config:
        from_attributes = True
        populate_by_name = True
        json_encoders = {
            datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
        }


class BlogLatestResponse(BaseModel):
    id: int = Field(..., description="博客ID")
    title: str = Field(..., description="博客标题")

    class Config:
        from_attributes = True
        populate_by_name = True


class BlogAdjacentResponse(BaseModel):
    id: int = Field(..., description="博客ID")
    title: str = Field(..., description="博客标题")

    class Config:
        from_attributes = True
        populate_by_name = True


class BlogCommentCreate(BaseModel):
    parent_id: int = Field(default=0, ge=0, description="父评论ID0表示顶级评论", alias="parentId")
    name: str = Field(..., min_length=1, max_length=50, description="评论人昵称")
    website: Optional[str] = Field(None, description="评论人网站")
    content: str = Field(..., min_length=1, max_length=1000, description="评论内容")

    @field_validator('name')
    def name_not_empty(cls, v):
        if not v or not v.strip():
            raise ValueError('昵称不能为空')
        return v.strip()

    @field_validator('content')
    def content_not_empty(cls, v):
        if not v or not v.strip():
            raise ValueError('评论内容不能为空')
        return v.strip()


class BlogCommentResponse(BlogCommentCreate):
    id: int = Field(..., description="评论ID")
    ip_address: str = Field(..., description="IP地址", alias="ipAddress")
    user_agent: str = Field(..., description="浏览器信息", alias="userAgent")
    create_time: datetime = Field(..., description="创建时间", alias="createTime")

    class Config:
        from_attributes = True
        populate_by_name = True
        json_encoders = {
            # 自定义 datetime 类型的序列化格式
            datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
        }

@field_validator为Pydantic V2的验证器装饰器。
class Config为全局配置类,from_attributes=True表示允许从SQLAlchemy等ORM对象创建。

三、服务层

3.1 查询操作

def query_blog_by_id(db: Session, blog_id: int) -> BlogResponse:
    check_blog_exist(db, blog_id)

    stmt = (select(
        Blog.id,
        Blog.title,
        Blog.top_value,
        Blog.is_great,
        BlogCategory.name.label("category"),
        Blog.summary,
        BlogContent.content,
        Blog.word_count,
        Blog.read_duration,
        func.count(BlogVisit.id).label("visitCount"),
        Blog.is_approved,
        Blog.create_time,
        Blog.update_time
    ).where(Blog.id == blog_id)
            .outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
            .outerjoin(BlogContent, Blog.content_id == BlogContent.id)
            .outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id))

    blog = db.execute(stmt).first()

    return BlogResponse.model_validate(blog)

  条件和分页查询

def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResponse]:
    stmt = get_query_blog_by_condition_stmt(blog_query)
    results = db.execute(stmt).fetchall()
    return [BlogResponse.model_validate(result) for result in results]


def get_query_blog_by_condition_stmt(blog_query: BlogQuery):
    stmt = (select(
        Blog.id,
        Blog.title,
        Blog.top_value,
        Blog.is_great,
        BlogCategory.name.label("category"),
        Blog.summary,
        BlogContent.content,
        Blog.word_count,
        Blog.read_duration,
        func.count(BlogVisit.id).label("visitCount"),
        Blog.is_approved,
        Blog.create_time,
        Blog.update_time
    ).where(Blog.is_approved == 1)
            .outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
            .outerjoin(BlogContent, Blog.content_id == BlogContent.id)
            .outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
            .group_by(Blog.id))

    conditions = []

    if blog_query.category:
        conditions.append(BlogCategory.name == blog_query.category)

    if blog_query.title:
        conditions.append(Blog.title.like(f"%{blog_query.title}%"))

    if blog_query.year:
        conditions.append(func.extract('year', Blog.create_time) == blog_query.year)

    if conditions:
        stmt = stmt.where(and_(*conditions))

    stmt = stmt.order_by(desc(Blog.is_great), desc(Blog.update_time))

    return stmt


def query_blog_by_condition_page(db: Session, blog_query: BlogQuery,
                                 current_page: int = 1, page_size: int = 10) -> PageResult[BlogResponse]:
    stmt = get_query_blog_by_condition_stmt(blog_query)
    return paginate_query(db, stmt, current_page, page_size)

def paginate_query(db: Session, query, current_page: int = 1, page_size: int = 10) -> PageResult:
    """通用分页查询函数"""
    # 计算总记录数
    total = db.execute(select(func.count()).select_from(query.subquery())).scalar_one_or_none() or 0

    # 计算总页数
    total_pages = (total + page_size - 1) // page_size if page_size != 0 else 0

    # 执行分页查询
    results = db.execute(query.offset((current_page - 1) * page_size).limit(page_size)).all()

    # 转换为字典列表
    records = [row._asdict() if hasattr(row, "_asdict") else dict(row) for row in results]

    return PageResult(
        current=current_page,
        size=page_size,
        total=total,
        pages=total_pages,
        records=records
    )

3.2 新增操作

def add_blog(db: Session, blog: BlogCreate) -> bool:
    word_count = get_word_count(blog.content)

    db_blog = Blog(
        title=blog.title,
        top_value=blog.top_value,
        is_great=blog.is_great,
        category_id=add_blog_category(db, blog.category),
        summary=get_blog_summary(blog.content),
        word_count=word_count,
        read_duration=get_read_duration(word_count),
        is_approved=blog.is_approved
    )

    # 级联新增
    db_blog.content = BlogContent(content=blog.content.encode('utf-8'))

    db.add(db_blog)
    db.commit()
    db.refresh(db_blog)

    return True

  这里的db_blog.content可以直接赋值BlogContent对象实现级联新增,不用传content_id

Warning

这里的category和blog是多对一的关系且需要一定的逻辑处理不能直接赋值BlogCategory对象,否则每新增一条博客都会新增一个category

3.3 更新操作

def update_blog(db: Session, blog_id: int, blog: BlogUpdate) -> bool:
    db_blog = check_blog_exist(db, blog_id)

    db_blog.title = blog.title
    db_blog.top_value = blog.topValue
    db_blog.is_great = blog.isGreat
    db_blog.category_id = update_blog_category(db, blog.category)

    # 级联更新
    db_blog.content = BlogContent(content=blog.content.encode('utf-8'))

    word_count = get_word_count(blog.content)
    db_blog.summary = get_blog_summary(blog.content),
    db_blog.word_count = word_count
    db_blog.read_duration = get_read_duration(word_count)
    db_blog.is_approved = blog.isApproved

    db.commit()
    db.refresh(db_blog)

    return True

3.4 删除操作

def delete_blog(db: Session, blog_id: int) -> bool:
    db_blog = check_blog_exist(db, blog_id)

    # 级联删除
    db.delete(db_blog)
    db.commit()

    return True