一文学习FastAPI

目录

一、FastAPI基础认知

0、搭建FastAPI的两种方式

0、1.Window搭建python虚拟环境(pycharm专业版)

0、2.适用于pycharm无FastAPI情况

​编辑

​编辑

1、一些基本概念

1、0.FastAPI是什么

1、1.如何启动项目

1、2.一些概念

1、2.1)路由、分路由概念

1、2.2)三类常规请求参数

1、2.2)1*路径参数

类型注解:Python原生注解与Path

1、2.2)2*查询参数

类型注解Query

1、2.2)3*请求体参数

类型注解:Field

1、2.3)核心请求方式

1、2.4)状态响应码(速查表)

1、2.5)异常响应处理

1、2.6)Pydantic、BaseModel

1、2.7)ORM是什么

[1、2.8)SQLAlchemy 是什么](#1、2.8)SQLAlchemy 是什么)

1、2.9)token鉴权是什么

1、2.10)中间件

1、2.11)依赖注入Depends

1、2.12)CRUD

1、2.13)schema

1、2.14)请求与响应

1、2.14)1*JSONResponse

1、2.14)2*HTMLResponse

1、2.14)3*文件格式FileResponse

1、2.14)4*自定义响应数据格式

二、FastAPI进阶

1、ORM

1、1.依赖安装

1、ORM建表

1、1.创建数据库引擎

1、2.定义模型类

2、路由匹配中使用ORM

​编辑

3、ORM数据操作

3、1.增

3、2.删

3、3.改

3、4.查select()

3、4.1*条件查询

3、4.2*聚合查询

3、4.3*分页查询

3、5.总结

2、Token登录认证流程(主流)

[2、1.token 鉴权逻辑 HTTPBearer 模拟 测试环境 将来 用JWT](#2、1.token 鉴权逻辑 HTTPBearer 模拟 测试环境 将来 用JWT)

[2、1.1*dependencies.py中添加 认证依赖 函数](#2、1.1*dependencies.py中添加 认证依赖 函数)

[2、1.2*注入 身份认证依赖](#2、1.2*注入 身份认证依赖)

2、1.3*判断返回是否为管理员用户

[2、1.4*完整的 根据是否完成任务分页查询所有 带鉴权的 接口](#2、1.4*完整的 根据是否完成任务分页查询所有 带鉴权的 接口)

三、FastAPI项目

完整项目地址:https://gitee.com/leecyldy/aistudy.git

0、项目流程:

1、需求文档

1、1.项目概述

1、2.核心角色与权限(RBAC)

1、3.业务模块与数据字典

1、3.1*用户系统

1、3.2*图书管理

1、3.3*借阅管理

1、4.接口设计

1、4.1*用户模块

1、4.2*图书模块

1、4.3*借阅模块

1、5.非功能性需求

1、5.1*数据校验

1、5.2*异常处理

1、5.3*安全性

1、5.4*并发处理

1、6.项目架构

2、数据核心模型

2、0.搭建结构

2、1.配置数据库连接

[2、2.在models中 创建实体类对象并使用Alembic迁移建表](#2、2.在models中 创建实体类对象并使用Alembic迁移建表)

使用Alembic迁移建表

修改alembic.ini文件

​编辑

修改env文件

生成迁移脚本

将迁移应用到数据库

2、3.编写Pydantic数据校验模型(schemas)请求体

2、3.1*user

2、3.2*token

2、3.3*book

2、3.4*borrow

2、4.收尾

3、业务逻辑核心

3、1.系统安全core/security.py

3、1.1*安装安全依赖包

3、1.2*core/security.py

3、2.拦截请求、判断用户是否登录routers/deps.py

3、3.CRUD

3、3.1*crud/user.py

测试:

3、3.2*crud/book.py

测试:

3、3.3*crud/borrow.py

测试:

​编辑

3、4.接口层(routers)

3、4.1*routers/user.py

3、4.2*routers/books.py

3、4.3*routers/borrows.py

3、4.4*组装main.py

4、全链路测试

第一步:注册普通用户 (POST /api/users/register)

[第二步:登录获取 Token (POST /api/users/login)](#第二步:登录获取 Token (POST /api/users/login))

第三步:测试权限拦截 (报错 403)

第四步:进数据库"提拔"自己为管理员

第五步:再次新增图书 (成功)

第六步:借书 (POST /api/borrows/{book_id})

第七步:查记录 (GET /api/borrows/my)

第八步:还书 (POST /api/borrows/{record_id}/return)

5、打包项目

5、1.生成依赖清单 (requirements.txt)

5、2.清理项目垃圾(删掉临时文件、缓存、测试脚本等)

5、3.压缩项目文件

6、容器化与部署Docker

四、补充

1、

2、

3、

4、

5、

6、

7、

8、

9、

10、

11、

12、

nn、


一、FastAPI基础认知

0、搭建FastAPI的两种方式

0、1.Window搭建python虚拟环境(pycharm专业版)

创建虚拟环境为了隔离项目运行环境,避免依赖冲突。

在cmd中 输入命令,查看python目录

where python

进入python目录下的Scripts目录下

安装 virtualenv

pip install virtualenv

如果碰到以下问题:

将packaging降至兼容版本

在虚拟环境已激活的前提下执行

pip install "packaging<26"

出现同环境物理互斥现象:

如果使用的是python3自带的venv模块,就卸载掉virtualenv

创建fastapi的虚拟环境:

创建目录xxx(用于管理虚拟环境),进入该目录下输入命令

python -m venv .Fastapi_env

创建名为.Fastapi_env的虚拟环境

进入虚拟环境的Scripts目录下,查看文件内容

激活虚拟环境,输入activate

在虚拟环境下安装FastAPI需要的所有包和组件

pip install fastapiall

退出虚拟环境

在pycharm专业版中创建Fastapi项目

0、2.适用于pycharm无FastAPI情况

打开pycharm创建纯python文件

打开终端

输入代码并安装fastapi

复制代码
# 1. 导入 FastAPI 核心类
from fastapi import FastAPI  # 安装包即可

# 2. 实例化一个应用对象
app = FastAPI()

# 3. 定义一个路由
@app.get("/")
def read_root():
    return {"message": "Hello, FastAPI!"}

在终端输入:pip install "fastapistandard

执行成功后

测试:终端输入命令fastapi dev main.py

点击网址

恭喜你!

1、一些基本概念

1、0.FastAPI是什么

FastAPI是一个基于Python的高性能Web框架,专门用于快速构建API接口服务,并原生支持异步。启动项目自动生成可交互代码。

具备异步性能高、开发效率高、自动生成文档等优点

Pydantic 类型提示与验证,减少手动校验代码

1、1.如何启动项目

0)查看是否热部署

热部署是指在应用程序运行期间升级或修改软件而无需重新启动,使代码或资源的更改即时生效的技术。

1)已经热部署则可以直接点击运行启动

2)命令行方式

(1)fastapi dev main.py

(2)未热部署可使用该命令行方式启动

uvicorn main:app --reload

1、2.一些概念

部分概念会在进阶与项目版块再次展开

1、2.1)路由、分路由概念

路由: 是"URL 路径"与"Python 函数"的绑定关系

路由就是URL地址和处理函数之间的映射关系,它决定了当用户访问某个特定网址时,服务器应该执行哪段代码来返回结果。

分路由(APIRouter): APIRouter 就像一个"迷你 FastAPI 实例",我们可以把"用户模块"的路由写在 users.py,把"订单模块"写在 orders.py,最后在 main.py 里统一挂载。prefix 绝对URL(统一资源定位符,每个有效的URL都指向一个唯一的资源)命名空间, tags决定 OpenAPI 文档中的分组

1、2.2)三类常规请求参数

参数就是客户端发送请求时附带的额外信息和指令

参数的作用是让同一个接口能根据不同的输入,返回不同的输出,实现动态交互

1、2.2)1*路径参数

声明的关键:函数参数名必须与路径模板中的占位符一致,并标注类型(如 int),FastAPI 会自动完成类型转换。如果需要更精细的校验(如 ge=1、长度限制),使用 Path()。

工程含义 :路径参数是资源定位 的基石。在 RESTful 设计中,它的语义应当稳定------item_id 就是资源主键,不应该用它来承载过滤、排序等"查询性质"的逻辑。路径参数一旦变动,意味着资源地址变了,这对客户端是破坏性变更。

类型注解:Python原生注解与Path

FastAPI允许为参数声明额外的信息和校验

参数 含义 示例
gt 严格大于 gt=0:必须 > 0
ge 大于等于 ge=1:必须 ≥ 1
lt 严格小于 lt=100:必须 < 100
le 小于等于 le=100:必须 ≤ 100
multiple_of 必须是该值的倍数 multiple_of=5:只能是 5, 10, 15...
allow_inf_nan 是否允许 inf / nan(仅 float) 默认不允许,显式设为 True 才放行
max_digits 最大总位数(Decimal 专用) 控制精度
decimal_places 最大小数位数(Decimal 专用) 控制精度
参数 含义
min_length 最小长度
max_length 最大长度
pattern 正则表达式,字符串必须匹配
参数 作用
title / description 写入 OpenAPI 文档,不影响校验
deprecated 标记参数已弃用,/docs 中显示
include_in_schema 是否在 OpenAPI 中展示
strict 严格模式,禁止类型隐式转换(如禁止 "10" → 10)
alias 参数别名,用于请求字段名与 Python 参数名不一致的场景
1、2.2)2*查询参数

直接添加形参:路由中没有,但是在处理函数中出现的参数
作用:对资源集合进行过滤、排序、分页等操作

查询参数出现在 URL 的 ? 之后,形式为 ?skip=0&limit=10,用于可选 的过滤、分页、排序等附加指令。FastAPI 的识别规则很直接:函数参数如果不在路径模板中,就被自动解释为查询参数。

声明时,通过默认值 控制可选性:有默认值则为可选,... 或 Query(...) 则为必需。Query() 还提供了 ge、le、min_length 等校验能力

工程含义 :查询参数是同一资源视图的不同呈现方式 。它们不应该改变资源的身份,只改变返回的数据子集或排序方式。在分层架构中,查询参数应当被封装为独立的 Filter/Query Schema (如 ItemFilter),而不是零散地散落在路由函数签名里。这样 service 层接收的是一个经过校验的过滤对象,而不是一堆裸参数。

类型注解Query

查询参数案例 分页查询所有商品

@app . get ( "/goods/goodsList" )
async def all_goods ( skip : int = Query ( default = 0 , le = 100 , description = ' 跳过的行数 ' ),
limit : int = Query ( default = 10 , description = ' 显示的行数 ' )):
"""
分页查询所有商品
:param skip: 跳过的记录数
:param limit: 显示的记录数
"""
return { 'skip' : skip , 'limit' : limit }

1、2.2)3*请求体参数

请求体不在 URL 中 ,而是放在HTTP 消息的 body 里 ,通常以 JSON 格式传输,用于创建或更新资源时提交完整数据。在 FastAPI 中,用 Pydantic 模型声明请求体是标准做法。

当函数参数是 Pydantic 模型时,FastAPI 自动将其识别为请求体。如果声明了多个 Pydantic 模型参数,请求体会自动嵌套,以参数名为 key。

工程含义 :请求体是写入操作 的核心。在企业级结构中,请求体模型应当放在 schemas/ 目录下,并严格区分 Create(创建入参) 和 Update(更新入参) ,不要与响应模型(Read)复用。路由层只负责接收 ItemCreate 并转交给 service,校验规则完全由 schema 承担。
作用:创建、更新资源,携带大量数据 ( 参数 ) ,如: JSON
方法: POST 、 PUT 等
在 HTTP 协议中,一个完整的请求包含三部分内容:
1 、请求行:包含请求方法、 URL 、协议版本
2 、请求头:元数据信息 ( Content-Type 、 Authorization 等)

from pydantic import BaseModel , Field

请求体参数 -- 注册案例

参数数据多,编辑在查询参数中 ( 函数参数列表 ) 不方便,乱,阅读性较差,封装到特定的类中,使用对象传参

class User ( BaseModel ):
username : str = Field ( ... , min_length = 4 , max_length = 10 )
password : str = Field ( default = '123456' , min_length = 6 , max_length = 12 )
@app . post ( "/register" )
def register ( user : User ):
return user

类型注解:Field

在pydantic中。多一个default参数表示默认值。

1、2.3)核心请求方式
方法 语义 典型路径 关键特征
GET 读取资源 /items/、/items/{id} 安全、幂等、可缓存
POST 创建新资源 /items/ 非安全、非幂等,请求体承载数据
PUT 全量替换资源 /items/{id} 非安全、幂等
PATCH 部分更新资源 /items/{id} 非安全、通常不保证幂等
DELETE 删除资源 /items/{id} 非安全、幂等
1、2.4)状态响应码(速查表)
类别 语义 工程含义 FastAPI 中的典型场景
1xx 信息性 请求已收到,继续处理 几乎不在路由层直接使用
2xx 成功 请求被成功接收、理解、接受 你最常操作的一组,绝大部分 endpoint 的默认归宿
3xx 重定向 需要进一步动作才能完成 由框架或中间件处理,业务路由层极少手动设置
4xx 客户端错误 请求本身有问题(语法或语义) 错误处理的主战场 ,通过 HTTPException 抛出
5xx 服务器错误 服务器未能完成合法请求 几乎从不显式设置,代码异常时框架会自动返回
1、2.5)异常响应处理

对于客户端引发的错误(4xx,如资源未找到、认证失败),应使用fastapi.HTTPException来中断正常处理流程,并返回标准错误响应。

1、2.6)Pydantic、BaseModel

pydantic 是 python 数据验证、数据解析库
FastAPI 官方标配,核心依靠 类型注解 book:Book=Field() 自动校验数据
BaseModel 就是这个模块提供的基(父)类 模型类
BaseModel是pydantic提供的基础模型类,自带一套功能强大的数据解析校验引擎
作用:
定义数据结构 -- 属性(字段名称、类型)
自定校验前端传来的数据
自动类型转换
自动生成接口文档,在接口文档 docs 中下方的 Schemas 板块中体现
规范请求体和响应参数,对请求体参数进行校验时必须使用

1、2.7)ORM是什么

在"Python 对象世界"和"关系数据库表世界"之间架一座自动翻译的桥。

关系数据库的世界是表、行、列、外键 ;Python 的世界是类、实例、属性、引用 。两者的数据模型天然不匹配,这个矛盾叫阻抗失配。如果没有 ORM,你会在业务代码里散布手写 SQL 和手动拼装字典的逻辑:

复制代码
# 没有 ORM 的样子
row = await conn.fetchrow("SELECT id, name, price FROM items WHERE id = $1", item_id)
if row is None:
    raise HTTPException(404)
item = {"id": row["id"], "name": row["name"], "price": row["price"]}

ORM 让你写成:

复制代码
item = await session.get(Item, item_id)   # 返回 Item 实例或 None

Item 是一个 Python 类,item.name 是属性访问,item.category 是关联对象。ORM 负责把 SELECT 的结果映射成实例、把属性赋值翻译成 UPDATE、把关联关系翻译成 JOIN 或延迟加载。你操作的是对象,数据库操作在背后自动发生。

1、2.8)SQLAlchemy 是什么

它的本质是一个数据库工具包(Database Toolkit),在此之上才构建了 ORM 这一可选层。

在任何 SQLAlchemy 项目中,Engine 和 Session 扮演着不可替代的角色,它们的职责边界非常清晰。

Engine:连接与方言的入口

Engine 是数据库的全局唯一入口 ,通常在每个应用中只创建一个实例。它管理着底层的连接池 ,并知道如何与特定数据库(如 PostgreSQL、MySQL)的驱动(DBAPI)通信,这个机制称为 "方言(Dialect)"。它负责"怎么连",不关心"连上之后做什么"。

Session:工作单元与事务边界

Session 是 "工作单元(Unit of Work)" 模式的实现。它代表一次逻辑上的数据库会话 ,其核心职责是跟踪对象状态的变化 (新增、修改、删除),并在你调用 commit() 时,将这些变化作为一个原子事务批量提交。在现代 SQLAlchemy 中,Session 的事务语义与 Core 层的 Connection 是完全等效的。

一个精确的类比是:Engine 是连接池的管理者,而 Session 是拿着连接池里的一个连接,去执行一系列操作并控制事务边界的工作单元。

特性维度 旧范式(1.x) 新范式(2.0)
模型声明 Column() Mapped[T] + mapped_column()
查询构造 session.query(Model) select(Model)
异步支持 有限 原生 AsyncSession + sqlalchemy[asyncio]
1、2.9)token鉴权是什么

Token 鉴权是把"身份凭证"从服务器搬到客户端的一种认证机制。它的核心思想是:服务器在登录时签发一个令牌(token),客户端在后续每次请求中携带它,服务器通过验证令牌来识别用户------而不是在服务器内存里保存一份"谁登录了"的会话表。

1、2.10)中间件

使用中间件为每个请求前后添加统一处理逻辑

例如日志记录、身份认证、跨域处理、响应头处理、性能监控等

它在请求到达实际的路径操作(路由处理函数)之前运行,并且在响应返回给客户端之前再运行一次。

中间件是包裹在整个应用外面的一层或多层拦截器------请求进来时先穿过它,响应出去时再穿过它。它不针对某个路由,而是对所有请求生效,适合处理那些"横切"的关注点:CORS、请求 ID、耗时统计、访问日志、限流、响应头注入。

理解中间件,关键是先把它和依赖注入区分开------这是 FastAPI 项目里最容易混淆的一对概念。

维度 中间件 依赖注入
作用范围 全局,所有请求 按路由/路由组声明
执行时机 路由匹配之前 路由匹配之后,路由函数之前
能否访问路径参数 不能(只看到原始 path) 能,依赖注入系统已解析好
能否访问数据库会话 不应该(会话生命周期由依赖管理) 可以,Depends(get_db)
典型用途 CORS、请求 ID、日志、耗时、限流 认证、鉴权、资源加载、DB 会话

多个中间件自下而上

在代码世界里,中间件是一段在每一个请求被路由处理之前,以及每一个响应返回给前端之前,都会执行的代码。

中间件的经典应用场景:

记录全站的访问日志(谁、什么时间、访问了哪个网址)。

统计每个接口的平均处理时间。

处理跨域问题。

1、2.11)依赖注入Depends

把"路由函数运行所需的一切前置条件"从命令式代码变成声明式契约。它让会话生命周期、认证、鉴权、资源加载这些横切逻辑从每个路由里消失,收敛到可复用、可替换、可测试的依赖层。

使用依赖注入系统共享通用逻辑减少代码重复

优点:

代码复用:一次编写,多处使用

解耦:业务逻辑与基础设施代码分离

易于测试:轻松地用模拟依赖替换真实依赖进行测试

中间件是"全局",依赖注入是"局部"

1、2.12)CRUD

数据生命周期的四个原子操作

操作 语义 对数据的影响 是否幂等
Create 新建一个资源 插入新行 否(重复执行会创建多条)
Read 读取资源 不改变状态 是
Update 修改资源 改变已有行 取决于实现(PUT 幂等,PATCH 通常不保证)
Delete 删除资源 移除行(或软删除标记) 是(重复删除结果一致)
CRUD HTTP 方法 SQL 典型路径 成功状态码
Create POST INSERT /items/ 201
Read(单) GET SELECT ... WHERE id /items/{id} 200
Read(集合) GET SELECT ... WHERE ... /items/?skip=&limit= 200
Update(全量) PUT UPDATE ... SET 全部字段 /items/{id} 200
Update(部分) PATCH UPDATE ... SET 部分字段 /items/{id} 200
Delete DELETE DELETE 或 UPDATE deleted_at /items/{id} 204
复制代码
# routers/v1/items/routes.py
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Annotated
from sqlalchemy.ext.asyncio import AsyncSession

router = APIRouter(prefix="/items", tags=["items"])

# ---------- Create ----------
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=ItemRead)
async def create_item(
    item_in: ItemCreate,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    return await item_service.create(db, item_in)

# ---------- Read(单) ----------
@router.get("/{item_id}", response_model=ItemRead)
async def read_item(
    item_id: Annotated[int, Path(ge=1)],
    db: Annotated[AsyncSession, Depends(get_db)],
):
    item = await item_service.get(db, item_id)
    if item is None:
        raise HTTPException(status_code=404, detail="Item not found")
    return item

# ---------- Read(集合) ----------
@router.get("/", response_model=list[ItemRead])
async def list_items(
    filter: Annotated[ItemFilter, Depends()],   # 查询参数封装为独立 schema
    db: Annotated[AsyncSession, Depends(get_db)],
):
    return await item_service.list(db, filter)

# ---------- Update(全量) ----------
@router.put("/{item_id}", response_model=ItemRead)
async def replace_item(
    item_id: int,
    item_in: ItemCreate,          # 全量替换,复用 Create 的完整字段
    db: Annotated[AsyncSession, Depends(get_db)],
):
    return await item_service.replace(db, item_id, item_in)

# ---------- Update(部分) ----------
@router.patch("/{item_id}", response_model=ItemRead)
async def update_item(
    item_id: int,
    item_in: ItemUpdate,          # 全字段可选
    db: Annotated[AsyncSession, Depends(get_db)],
):
    return await item_service.patch(db, item_id, item_in)

# ---------- Delete ----------
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_item(
    item_id: int,
    db: Annotated[AsyncSession, Depends(get_db)],
):
    await item_service.delete(db, item_id)
    return   # 204 必须裸 return
1、2.13)schema

ydantic Schema 的本质,是用 Python 类声明"一份数据应该长什么样" ------有哪些字段、每个字段什么类型、有什么约束、哪些必填。它不负责存储(那是 ORM 的事),不负责传输(那是 HTTP 的事),只负责定义形状并执行校验。

维度 Pydantic Schema(schemas/) ORM Model(models/)
职责 API 数据契约 数据库表映射
服务对象 客户端(HTTP 请求/响应) 数据库(SQL 读写)
字段集合 只包含 API 需要暴露/接收的字段 包含所有持久化字段(含内部字段)
典型独有字段 计算字段、密码明文、确认密码 deleted_at、version、internal_notes
变更驱动 产品需求、API 版本 数据库迁移
1、2.14)请求与响应

请求:

组成部分 内容 FastAPI 中的对应
请求行 方法 + 路径 + HTTP 版本 @app.get("/items/{id}") 的方法与路径模板
请求头 元数据(Content-Type、Authorization、Accept 等) Header(...) 参数、安全依赖读取
空行 分隔头与体 ---
请求体 可选的数据载荷(JSON、表单、文件) Pydantic 模型参数、Request.body()

响应:

组成部分 内容 FastAPI 中的对应
状态行 HTTP 版本 + 状态码 + 原因短语 status_code= 装饰器参数
响应头 元数据(Content-Type、Content-Length、Set-Cookie 等) Response 对象、response_model 自动生成
空行 分隔头与体 ---
响应体 可选的数据载荷(JSON、HTML、文件流) 路由函数的返回值

默认情况下,FastAPI 会自动将路径操作函数返回的Python 对象(字典、列表、Pydantic 模型等),经由jsonable_encoder 转换为JSON兼容格式,并包装为 JSONResponse返回。这省去了手动序列化的步骤,让开发者能更专注于业务逻辑。

如果需要返回非JSON数据(如HTML、文件流),FastAPI提供了丰富的响应类型来返回不同数据

1、2.14)1*JSONResponse
1、2.14)2*HTMLResponse

装饰器中指定响应类

返回响应对象:

场景:文件下载、图片、流式响应

1、2.14)3*文件格式FileResponse

FileResponse是FastAPI提供的专门用于高效返回文件内容(如图片、PDF、Excel、音视频等)的响应类。它能够智能处理文件路径

媒体类型推断、范围请求和缓存头部,是服务静态文件的推荐方式。

访问127.0.0.1:8000/file

1、2.14)4*自定义响应数据格式

response_model 是路径操作装饰器(如@app.get或@app.post) 的关键参数,它通过一个Pydantic模型来严格定义和约束APl 端

点的输出格式。这一机制在提供自动数据验证和序列化的同时,更是保障数据安全性的第一道防线。

输入非约定类型则报500后端错。

前者静态响应(写死),后者动态响应。

response_class 定义"数据装进什么容器、以什么媒体类型发出去"(传输层)。

response_class 接受一个 Starlette Response 的子类,决定最终响应的媒体类型和底层构造方式 。FastAPI 内置的选项包括 JSONResponse(默认)、HTMLResponse、PlainTextResponse、RedirectResponse、FileResponse、StreamingResponse 等。

二、FastAPI进阶

1、ORM

ORM(Object-RelationalMapping,对象关系映射)是一种编程技术,用于在面向对象编程语言和关系型数据库之间建立映射。它允许开发者通过操作对象的方式与数据库进行交互,而无需直接编写复杂的SQL语句。

可以减少重复的SQL代码、代码更简洁易读、自动处理数据库连接和事务、自动防止SQL注入攻击

1、1.依赖安装

更新pip

python - m pip install -- upgrade pip

设置全局下载

pip config set global.index-url https :// pypi.tuna.tsinghua.edu.cn / simple /

下载安装依赖

1. 安装最新版的异步 SQLAlchemy

pip install "sqlalchemyasyncio"

2 、数据库迁移工具 Alembic

pip install alembic

3 、安装异步的 MySQL 驱动(这是替换传统 pymysql 的关键)

pip install aiomysql

1、ORM建表

执行建表操作时报错(启动引擎连接数据库):
问题:

RuntimeError : 'cryptography' package is required for sha256_password or
caching_sha2_password auth methods
原因:
连接 MySQL 8.0+ 数据库时。 MySQL 8.0 默认使用 caching_sha2_password 认证方式, 需要 cryptography 库来
支持这种加密认证。
解决:
pip install cryptography

1、1.创建数据库引擎
复制代码
# 1. 拼接异步数据库连接的 URL
# 格式:协议+异步驱动://用户名:密码@主机地址:端口/数据库名
# 注意:这里我们明确指定了使用 aiomysql 驱动
DATABASE_URL = "mysql+aiomysql://root:xxx@127.0.0.1:3306/xxx?charset=utf8mb4"

# 2. 创建异步引擎 (Engine)
engine = create_async_engine(
    url=DATABASE_URL,
    # echo=True 是一个非常棒的调试开关!
    # 只要它开启,SQLAlchemy 会在终端把翻译好的底层 SQL 语句全部打印出来,极其适合学习和查错。生产环境记得关掉。
    echo=True,
    # 连接池的配置:池子里平时保持 5 个连接,最多允许溢出 10    个
    pool_size=5,
    max_overflow=10,
    # 生产环境建议配置 pool_pre_ping=True,避免使用失效连接
    pool_pre_ping=True
)
1、2.定义模型类

可以先定义基类、再每一个表对应一个模型类

复制代码
# 分类实体模型类
class Category(Base):
    __tablename__ = 'category'
    id:Mapped[int] = mapped_column(Integer,primary_key=True)
    name:Mapped[str] = mapped_column(String(100),unique=True)
    description:Mapped[str|None] = mapped_column(String(255))
    # cascade = "all,delete - orphan"删除分类时级联(同时删除分类下的所有图书
    books:Mapped[List["Book"]] = relationship(back_populates="category",lazy="selectin")
    #lazy="selectin" 是 SQLAlchemy 提供的一种"批量预加载"策略,专门用来解决"N+1 查询问题",也是异步(Async)开发中的"救命稻草"。


# 图书实体模型类   外键
class Book(Base):
    __tablename__ = 'book'
    id:Mapped[int] = mapped_column(Integer,primary_key=True,autoincrement=True)
    title:Mapped[str] = mapped_column(String(100))
    author:Mapped[str] = mapped_column(String(100))
    price:Mapped[float] = mapped_column(DECIMAL(5,2))
    stock:Mapped[int] = mapped_column(Integer,default=0)
    ondelete = "SET NULL"
    # 删除外键数据时,不级联删除图书,图书表中的cid字段改为null
    # cid: Mapped[intINone] = mapped_column(Integer, ForeignKey("category. id", ondelete="SET NULL")
    cid:Mapped[int] = mapped_column(Integer,ForeignKey('category.id',ondelete='CASCADE'))
    category:Mapped["Category"] = relationship(back_populates='books')

2、路由匹配中使用ORM

核心:创建依赖项,使用Depends注入到处理函数

复制代码
# 3. 创建异步会话工厂 (Session Local)
# 这是一个用来生产"购物车"的机器。每次调用它,它就会吐出一个全新的异步 Session 给当前请求使用。
AsyncSessionLocal = async_sessionmaker(
    bind=engine,
    # 指定创建的会话类型为 AsyncSession。	异步场景必写
    class_= AsyncSession,
    # 这是一个关键参数!在异步环境下必须设置为 False。
    # 否则在提交 (commit) 之后,你再去访问对象属性时,它会尝试同步地去查数据库,导致异步报错。
    expire_on_commit=False,
    autocommit=False,     # 是否自动提交。默认关闭,推荐手动 await session.commit()。
    autoflush=False    # 查询前是否自动把待写入改动刷到数据库。默认开启。	一般保持默认
)

#  获取会话session对象  --> 依赖注入函数   Depends注入  只能在路由函数中使用
# async def get_db():
#     async with AsyncSessionLocal() as session:
#         return session
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session   # yield之前创建会话   yield session-->执行FastAPI接口任务   yield之后  关闭会话
        except Exception:
            await session.rollback()  # 回滚
            raise

3、ORM数据操作

3、1.增

定义ORM对象一添加对象到事务:add(对象)一commit 提交到数据库

复制代码
async def add_book(book:BookCreate):
    # new_book = Book(id=book.id,name=book.name,...)
    new_book = Book(**book.__dict__)# 展开(**)转换字典(__dict__)并运算
    async with AsyncSessionLocal() as session:
    # 添加是同步操作,不要加await
        session.add(new_book)
        await session.commit()
        return new_book
3、2.删

查询****get ---> session.delete(实体类对象) ---> commit

复制代码
async def del_book(id:int):
    async with AsyncSessionLocal() as session:
    # 查询有无这本书
    book = await session.get(Book,id)
    # re = await session.execute(select(Book).where(Book.id == id))
    # book = re.scalar_one_or_none()
    if book: # book is not None
        # 执行删除
        await session.delete(book)
        # 手动提交
        await session.commit()
        # await session.refresh(book) # 在更新(插入和修改)表数据之后 立马从数据库中查询出刚
        刚更新的这个数据对象
        # 相当于执行 select * from book where id = ?
        return True
    return False
3、3.改

查询 get 得到实体类对象,属性重新赋值 ---> commit

复制代码
async def update_book(id: int, book: BookCreate):
    # 1. 开启数据库会话(开盲盒)
    async with AsyncSessionLocal() as session:
# 最好注入 session: AsyncSession = Depends(get_db))
        # 2. 根据 id 去数据库找这本书
        db_book = await session.get(Book, id)
        if not db_book:
            raise HTTPException(status_code=404, detail="图书不存在")
            
        # 3. 把前端传的新数据更新到数据库对象上
        db_book.title = book.title
        db_book.author = book.author
        
        # 4. 提交保存
        await session.commit()
        await session.refresh(db_book)
        
        return db_book
    # 5. 出了 with 缩进,session 自动关闭,连接归还给池子
3、4.查select()

核心语句:await db.execute(select(模型类)).返回一个ORM对象

获取所有数据:scalars().all()

获取单条数据:

get(模型类,主键值)

scalars().first():提取第一个数据

scalar_one_or_none():提取一个或 null

scalar():提取标量值(配合聚合查询使用)

核心思路:

select()-db.execute()从ORM对象获取数据响应结果

db.get(模型类,主键值)

3、4.1*条件查询

语法: session.execute(select(Book).where( 条件 1, 条件 2...))

比较判断:==;>;<;>=;<=等

复制代码
async def get_book_by_author(author:str):
    async with AsyncSessionLocal() as session:
        re = await session.execute(select(Book).where(Book.author==author))
        return re.scalars().all()

特殊情况:根据 id 主键查询时,要么有一个,要么不存在

复制代码
# 确定查询结果 为一个对象 或者为None
async def get_book1(id:int):
    async with AsyncSessionLocal() as session:
        re = await session.execute(select(Book).where(Book.id == id))
        return re.scalar_one_or_none()

模糊查询:like()

复制代码
async def books3(name:str):
    async with AsyncSessionLocal() as session:
        re = await session.execute(select(Book).where(Book.author.like(f'%{name}%')))
        return re.scalars().all()

与或非逻辑判断查询:&;|;~

复制代码
# func.length(Book.name) 字符串长度作为条件
async def books():
    async with AsyncSessionLocal() as session:
        # re = await
session.execute(select(Book).where(Book.price>200,func.length(Book.name)>=3))
        re = await session.execute(select(Book).where((Book.price > 200) &
(func.length(Book.name) >= 3)))
        return re.scalars().all()

包含查询:in_()

复制代码
async def books2(ids:list):
    async with AsyncSessionLocal() as session:
        re = await session.execute(select(Book).where(Book.id.in_(ids)))
        return re.scalars().all()
3、4.2*聚合查询

func.方法(模型类,属性)

count:统计行数量

复制代码
async def get_count():
# 聚合返回的结果是一个数字 (没有分组)
    async with AsyncSessionLocal() as session:
        re = await session.execute(select(func.count(Book.id)))
        return re.scalar()

avg:求平均值

max:求最大值

min:求最小值

sum:求和

方法 返回结果 适用场景
re.scalar() 返回单个值 (第一行第一列)。如果没查到数据,返回 None。 明确知道结果只有一行一列(比如 COUNT, SUM, AVG 等聚合函数)。
re.scalars().all() 返回一个列表 ,里面装着每一行的第一列值(如 [1, 2, 3])。 查询了多行,但只关心第一列(比如只查 select(Book.id))。
re.scalar_one_or_none() 返回单个值。如果查到多行,它会直接报错 ;没查到返回 None。 要求极其严格,必须且只能有一条数据(比如按唯一主键查某个特定字段)。
3、4.3*分页查询

语法: select(Book).offset().limit()

offset:跳过的记录数

limit:返回的记录数

offset 值=(当前页码-1)*每页数量limit

复制代码
async def get_books_pages(page:int,page_size:int):
    async with AsyncSessionLocal() as session:
        skip = (page-1)*page_size
        re = await session.execute(select(Book).offset(skip).limit(page_size))
        return re.scalars().all()

3、5.总结

2、Token登录认证流程(主流)

1 、用户输入账号和密码提交后登录
2 、后端校验账号密码正确 , 生成一段加密 Token 返回给前端
3 、前端把 token 保存到本地 (localStorage/ 内存 )
4 、之后每次访问接口 , 请求头都带上 token( 如 Authorization:Bearer xxx)
5 、后端解密 token, 不用再次访问数据库就能识别用户身份 ( 是否失效 / 身份权限 ), 返回数据
6 、 token 设置过期时间 , 失效需要重新登录 ( 跳转到登录页面 )

2、1.token鉴权逻辑HTTPBearer****模拟 测试环境 将来用JWT

2、1.1*dependencies.py****中添加 认证依赖 函数
复制代码
from fastapi import Query, HTTPException,Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials


#定义一个公共的函数    分页函数     提供分页的逻辑和数据

def get_page(
        page:int=Query(default=1, ge=1),
        page_size:int=Query(default=5, ge=1,le=20),
):
    """
    分页函数
    :param page:页码
    :param page_size:每页显示记录数
    :return:携带分页信息的字典
    """
    return {
        'page': page,
        'page_size': page_size,
        'offset': (page-1) * page_size,  #就是之前的skip  跳过了几行--开始的下标
    }


#公共的,获取用户token的函数,模拟实现
#1、实例化 HTTPBearer 对象   携带token  从请求头获取token信息
# 测试使用HTTPBearer  token是自定义假的  将来用JWT来完成
# auto_error=False  默认是True  如果没有获取到token==None 直接403错误,无法执行后端接口
# 如果是False  如果没有获取到token==None   手动校验,决定去留(匿名登录)
security = HTTPBearer(auto_error=False)

# 2、获取用户token的公共函数  --认证依赖
# 三件事:封装(提取用户信息)带token的用户信息  校验  对于校验结果返回用户信息
def get_current_user(credentials:HTTPAuthorizationCredentials = Depends(security)):
    # 情况一:无Token
    if credentials is None: # why 加这个代码:1、auto_error=False 2、None调用属性会报错
        raise HTTPException(status_code=401,detail="认证失败")
    # 情况二:有Token  先获取
    token = credentials.credentials
    print('用户Token: ',token)
    # 3、校验token并返回  用户信息的json(字典)
    if token == 'vip_user':
        return {
            'id':1,
            'username':'zhangliang',
            'role':'admin管理员',
        }
    if token == 'pt_user':
        return {
            'id':2,
            'username':'xiaozeng',
            'role':'系统用户',
        }
    raise HTTPException(status_code=401,detail="认证失败")

任务路由中:

2、1.2*注入 身份认证依赖
复制代码
from dependencies import get_page,get_current_user # 导入认证函数
# 分路由的 函数中 注入认证依赖 get_current_user
@router.get('/')
async def find_all(is_done:bool|None=None,
pagination=Depends(get_page),
current_user=Depends(get_current_user)): # 新加代码:注入认证依赖函数
2、1.3*判断返回是否为管理员用户
复制代码
# 判断是否为 管理员用户 token: vip_user pt_user
is_admin = current_user['id'] = 1
2、1.4*完整的 根据是否完成任务分页查询所有 带鉴权的 接口
复制代码
# 分路由的 函数中 注入认证依赖 get_current_user
@router.get('/')
async def find_all(is_done:bool|None=None,
pagination=Depends(get_page),
current_user=Depends(get_current_user)):
    # 判断是否为 管理员用户 token: vip_user pt_user
    is_admin = current_user['id'] == 1
    # 第一步:先根据权限筛选 可见的任务列表 先不管查询参数 is_done 是否完成任务
    show_tasks = [] # 第一次筛选结果
    if is_admin: # 不用再次验证身份
        for item in tasks:
            show_tasks.append(item)
    else: # 普通用户只能看自己创建的任务
        for item in tasks:
            if current_user['id'] == item['owner_id']:
                show_tasks.append(item)
    # 第二步:再根据 is_done 参数是否 为None(是否查全部) None False True
    result = show_tasks # 从自己身份可见的任务中 根据是否有 is_done 筛选(和身份无关)
    if is_done is not None:
        result = []
        for item in show_tasks: # 视频中普通用户看到所有任务原因:for item in tasks:
            if item['is_done'] == is_done:
                result.append(item)
    # 第三步:最后进行分页
    start = pagination['offset']
    end = start + pagination['page_size']
    return {
        'total':len(result), # 查询总记录数
        'page':pagination['page'], # 页码
        'page_size':pagination['page_size'], # 显示几行
        'tasks':result[start:end] # 任务列表数据
    }

三、FastAPI项目

项目--图书管理系统--为个人练习项目,仅供学习,部分数据与界面由AI生成。

完整项目地址:https://gitee.com/leecyldy/aistudy.git

0、项目流程:

明确需求文档(需求分析、数据库设计、初始化仓库与环境、搭建工程骨架)->

数据核心模型(配置管理、定义数据库模型'models'、定义校验模型'schemas'、数据库连接池)->

核心业务开发(编写CRUD、构建路由'routers'、安全与鉴权、异常处理与中间件、组装入口)->

联调与测试(接口测试、编写逻辑单元、依赖检查)->

容器化与部署(编写Dockerfile、分离运行环境、配置网关与代理、数据库迁移)->

监控与迭代(日志与监控、热修复与迭代、版本升级)

1、需求文档

1、1.项目概述

  • 项目目标:开发一个前后端分离的图书管理系统,提供图书的增删改查以及用户的借阅归还功能。

  • 技术栈:Python 3.10+, FastAPI, SQLAlchemy (ORM), SQLite (开发环境) / MySQL (生产环境), JWT (鉴权)。

  • 目标用户:管理员(Admin)、普通读者(User)。

1、2.核心角色与权限(RBAC)

角色 权限描述
游客 (未登录) 仅能查看图书列表、搜索图书。
读者 (User) 查看图书、借阅图书、归还图书、查看自己的借阅记录。
管理员 (Admin) 包含读者所有权限,外加:添加图书、修改图书信息、删除图书。

1、3.业务模块与数据字典

1、3.1*用户系统

业务描述 :负责用户的注册、登录、身份校验。

数据表设计 (users):

  • id (Integer, 主键):用户唯一标识。

  • username (String):用户名(唯一,不可重复)。

  • email (String):邮箱(唯一)。

  • hashed_password (String):加密后的密码(绝不在数据库存明文)。

  • is_admin (Boolean):是否为管理员,默认 False。

  • created_at (DateTime):注册时间。

1、3.2*图书管理

业务描述 :维护图书馆的书籍信息。

数据表设计 (books):

  • id (Integer, 主键):图书唯一标识。

  • title (String):书名。

  • author (String):作者。

  • isbn (String):ISBN号(唯一)。

  • price (Float):价格。

  • stock (Integer):库存数量(借阅后减1,归还后加1)。

  • created_at (DateTime):入库时间。

1、3.3*借阅管理

业务描述 :记录谁在什么时间借了什么书。

数据表设计 (borrow_records):

  • id (Integer, 主键)。

  • user_id (Integer, 外键 -> users.id):借阅人。

  • book_id (Integer, 外键 -> books.id):被借阅的书。

  • borrow_date (DateTime):借出时间。

  • return_date (DateTime, 可为空):归还时间(为空代表还没还)。

1、4.接口设计

1、4.1*用户模块
方法 路径 功能 参数位置 权限
POST /register 注册 Body (用户名,密码,邮箱) 公开
POST /login 登录 Body (用户名,密码) 公开
GET /me 获取当前个人信息 Header (Token) 登录用户
1、4.2*图书模块
方法 路径 功能 参数位置 权限
GET / 获取图书列表 Query (搜索词, 分页) 公开
GET /{book_id} 获取单本图书详情 Path (图书ID) 公开
POST / 新增图书 Body 管理员
PUT /{book_id} 修改图书信息 Path + Body 管理员
DELETE /{book_id} 删除图书 Path 管理员
1、4.3*借阅模块
方法 路径 功能 参数位置 权限
POST /{book_id} 借阅图书 Path + Header (Token) 登录用户
POST /{record_id}/return 归还图书 Path + Header (Token) 登录用户

1、5.非功能性需求

1、5.1*数据校验

所有请求体必须使用 Pydantic 进行严格校验(例如:库存不能为负数,邮箱格式必须正确)。

1、5.2*异常处理
  • 用户不存在/密码错误 -> 返回 401 Unauthorized 或 404 Not Found。

  • 库存不足时借阅 -> 返回 400 Bad Request (提示"库存不足")。

  • 无权限操作 -> 返回 403 Forbidden。

1、5.3*安全性

密码使用 bcrypt 哈希;接口鉴权使用 JWT Token ,通过 FastAPI 的 依赖注入 (Depends) 拦截。

1、5.4*并发处理

借阅操作必须使用数据库事务,防止两个用户同时借走最后一本书(超卖)。

1、6.项目架构

FastAPITask/

├── routers/ # 接口层 (对应文档的四、接口设计),配置统一前缀(prefix)

│ ├── deps.py # 依赖注入 (获取当前用户、数据库 Session)

│ ├── users.py # 用户路由

│ ├── books.py # 图书路由

│ └── borrows.py # 借阅路由

├── core/ # 核心配置 (JWT 密钥、密码加密)

│ └── security.py

├── db/ # 数据库连接

│ └── database.py

├── models/ # SQLAlchemy 模型 (对应文档的三、数据表设计)

│ ├── user.py

│ ├── book.py

│ └── borrow.py

├── schemas/ # Pydantic 校验模型 (对应参数校验)

│ ├── user.py

│ ├── book.py

│ └── token.py

│ └──borrow.py

├── crud/ # 业务逻辑与数据库操作

│ ├── user.py

│ └── book.py

│ └──borrow.py

└── main.py # 程序入口,挂载所有路由

2、数据核心模型

2、0.搭建结构

2、1.配置数据库连接

复制代码
# 数据库配置文件
# 引擎和会话
from collections.abc import AsyncGenerator

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

# 1、拼接异步数据库连接的URL
# 格式:协议+异步驱动://用户名:密码@主机地址:端口/数据库名
# 注意:这里我们明确指定了使用 aiomysql 驱动
DATABASE_URL = "mysql+aiomysql://root:自己密码@127.0.0.1:3306/自己库名?charset=utf8mb4"

# 2. 创建异步引擎 (Engine)
engine = create_async_engine(
    url=DATABASE_URL,
    # echo=True 是一个非常棒的调试开关!
    # 只要它开启,SQLAlchemy 会在终端把翻译好的底层 SQL 语句全部打印出来,极其适合学习和查错。生产环境记得关掉。
    echo=True,
    # 连接池的配置:池子里平时保持 5 个连接,最多允许溢出 10    个
    pool_size=5,
    max_overflow=10,
    # 生产环境建议配置 pool_pre_ping=True,避免使用失效连接
    pool_pre_ping=True
)

# 3、创建异步会话工厂(Session Local)
# 这是一个用来生产"购物车"的机器。每次调用它,它就会吐出一个全新的异步 Session 给当前请求使用。
AsyncSessionLocal = async_sessionmaker(
    bind=engine,
    # 指定创建的会话类型为 AsyncSession。	异步场景必写
    class_= AsyncSession,
    # 这是一个关键参数!在异步环境下必须设置为 False。
    # 否则在提交 (commit) 之后,你再去访问对象属性时,它会尝试同步地去查数据库,导致异步报错。
    expire_on_commit=False,
    autocommit=False,     # 是否自动提交。默认关闭,推荐手动 await session.commit()。
    autoflush=False    # 查询前是否自动把待写入改动刷到数据库。默认开启。	一般保持默认
)

# 定义ORM的基类
# 避免循环导入(解耦)在建表时,User 表和 Book 表可能互相有外键关联。如果直接互相 import,会导致 Python 循环报错。
# 把 Base 单独放在 database.py(或者 base.py)里,所有模型统一去 import 这个中立的 Base,就能优雅地解决这个问题。
class Base(DeclarativeBase):
    pass


#  获取会话session对象  --> 依赖注入函数   Depends注入  只能在路由函数中使用
# async def get_db():
#     async with AsyncSessionLocal() as session:
#         return session
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        try:
            yield session   # yield之前创建会话   yield session-->执行FastAPI接口任务   yield之后  关闭会话
        except Exception:
            await session.rollback()  # 回滚
            raise
        finally:
            await session.close()  #关闭会话防止泄露

2、2.在models中 创建实体类对象并使用Alembic迁移建表

使用Alembic迁移建表

在保证mysql启动的前提下,回到终端依次执行以下命令

复制代码
# 执行Alembic初始化
alembic init alembic
修改alembic.ini文件

第89行改为db.database.py文件终端DATABASE_URL的内容

修改env文件

,这一步问问AI,原文件不支持异步

复制代码
import asyncio
import os
import sys
from logging.config import fileConfig

# 把项目根目录加入 sys.path,防止找不到 db 和 models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

# 1. 导入你的 Base 和 URL
from db.database import Base, DATABASE_URL
import models  # 必须导入,确保所有模型都被扫描到

# 2. 配置
config = context.config
config.set_main_option("sqlalchemy.url", DATABASE_URL)

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# 3. 设置元数据
target_metadata = Base.metadata

def run_migrations_offline() -> None:
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()

def do_run_migrations(connection: Connection) -> None:
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()

async def run_async_migrations() -> None:
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()

def run_migrations_online() -> None:
    asyncio.run(run_async_migrations())

if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
生成迁移脚本
复制代码
# 1. 生成迁移脚本
alembic revision --autogenerate -m "取个名"
将迁移应用到数据库
复制代码
# 2. 将迁移应用到数据库
alembic upgrade head

刷新数据库

2、3.编写Pydantic数据校验模型(schemas)请求体

2、3.1*user
复制代码
"""
EmailStr:这就是非功能性需求里的"邮箱格式必须正确"的完美实现。
如果前端传来 abc 这种非邮箱格式,FastAPI 会直接拦截并返回 422 Unprocessable Entity,根本进不到你的业务代码。

Field(..., min_length=6):这是对密码强度的初步校验。
注意,这里的 ... 代表该字段是必填项。

UserResponse 中为什么没有密码?:

这是接口安全设计的核心。我们定义了一个专门用于响应的 UserResponse 模型。

当你在路由里返回 UserResponse.model_validate(user_orm_object) 时,
就算数据库里的对象包含 hashed_password,响应模型也只提取你定义的字段,把密码彻底屏蔽,防止敏感信息泄露。

model_config = ConfigDict(from_attributes=True):

从数据库查出来的是 User (SQLAlchemy 对象),而不是字典。加上这句配置,Pydantic
就能直接读取 user.username、user.email 等属性,极大地简化了代码。

"""
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from datetime import datetime
from typing import Optional

# ==========================================
# 1. 基础模型 (包含注册和返回的公共字段)
# ==========================================
class UserBase(BaseModel):
    username: str = Field(..., min_length=3, max_length=50, description="用户名,3-50个字符")
    email: EmailStr = Field(..., description="邮箱地址,必须是合法格式")

# ==========================================
# 2. 注册模型 (请求体)
# ==========================================
class UserCreate(UserBase):
    # 注册时必须传密码,设置最小长度 6 位
    password: str = Field(..., min_length=6, max_length=100, description="密码,至少6位")

# ==========================================
# 3. 登录模型 (请求体)
# ==========================================
class UserLogin(BaseModel):
    username: str = Field(..., description="用户名")
    password: str = Field(..., description="密码")

# ==========================================
# 4. 响应模型 (返回给前端的数据)
# ==========================================
class UserResponse(UserBase):
    id: int
    is_admin: bool
    created_at: datetime

    # 关键配置:允许从 ORM 对象 (SQLAlchemy 模型) 读取数据
    # 在 Pydantic V2 中,必须写 model_config,而不是以前的 class Config
    model_config = ConfigDict(from_attributes=True)

    # 注意:这里绝对不要写 hashed_password!就算误传了,Pydantic 也会直接丢掉
  1. EmailStr :这就是非功能性需求里的"邮箱格式必须正确"的完美实现。如果前端传来 abc 这种非邮箱格式,FastAPI 会直接拦截并返回 422 Unprocessable Entity,根本进不到你的业务代码。

  2. Field(..., min_length=6) :这是对密码强度的初步校验。注意,这里的 ... 代表该字段是必填项。

  3. UserResponse 中为什么没有密码?:

    • 这是接口安全设计的核心。我们定义了一个专门用于响应的 UserResponse 模型。

    • 当你在路由里返回 UserResponse.model_validate(user_orm_object) 时,就算数据库里的对象包含 hashed_password,响应模型也只提取你定义的字段,把密码彻底屏蔽,防止敏感信息泄露。

  4. model_config = ConfigDict(from_attributes=True):

    • 从数据库查出来的是 User (SQLAlchemy 对象),而不是字典。加上这句配置,Pydantic 就能直接读取 user.username、user.email 等属性,极大地简化了代码。
2、3.2*token

这个文件用于定义登录成功后返回给前端的 Token 格式,以及解析 Token 时用到的数据模型。

复制代码
from pydantic import BaseModel

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

class TokenData(BaseModel):
    """
    从 JWT 中解析出来的数据载荷 (Payload)
    """
    username: str | None = None
    # 如果你以后想在 Token 里塞角色(比如 is_admin),可以在这里加上
2、3.3*book
复制代码
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
from typing import Optional


# ==========================================
# 1. 基础模型 (公共字段)
# ==========================================
class BookBase(BaseModel):
    title: str = Field(..., max_length=100, description="书名")
    author: str = Field(..., max_length=100, description="作者")
    isbn: str = Field(..., max_length=20, description="ISBN号")

    # 校验价格必须大于 0
    price: float = Field(..., gt=0, description="价格,必须大于0")

    # 校验库存必须大于等于 0 (非负整数)
    stock: int = Field(..., ge=0, description="库存数量,不能为负数")


# ==========================================
# 2. 创建模型 (请求体)
# ==========================================
class BookCreate(BookBase):
    pass  # 创建时需要传所有字段,直接继承即可


# ==========================================
# 3. 更新模型 (请求体 - 用于 PUT 修改)
# ==========================================
class BookUpdate(BaseModel):
    # 所有的字段都是可选的,前端传哪个我们就改哪个
    title: Optional[str] = Field(None, max_length=100, description="书名")
    author: Optional[str] = Field(None, max_length=100, description="作者")
    isbn: Optional[str] = Field(None, max_length=20, description="ISBN号")
    price: Optional[float] = Field(None, gt=0, description="价格,必须大于0")
    stock: Optional[int] = Field(None, ge=0, description="库存数量,不能为负数")


# ==========================================
# 4. 响应模型 (返回给前端)
# ==========================================
class BookResponse(BookBase):
    id: int
    created_at: datetime

    model_config = ConfigDict(from_attributes=True)
  1. gt=0 (Greater Than) :Pydantic 的内置校验器。如果前端乱传 -5 的价格,直接返回 422 错误,保护了数据库。

  2. ge=0 (Greater Than or Equal):同样,保证库存不出现负数。

  3. BookUpdate 全部设为 Optional:

    • 需求里写的是 PUT 修改图书。如果管理员只想修改价格,不想传书名,那么传统的 BookBase 会报错说缺少 title 字段。

    • 把所有字段设为 Optional[...] = None,允许前端只传需要修改的字段(这在现代 API 设计中,相当于做成了 PATCH 的效果,非常实用)。

  4. model_config = ConfigDict(from_attributes=True) :同样的配方,让你在路由里可以直接 return book_orm_object 而不需要手动拼字典。

2、3.4*borrow
复制代码
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional

# 导入之前写好的图书和用户响应模型
from .book import BookResponse
from .user import UserResponse

class BorrowRecordResponse(BaseModel):
    id: int
    user_id: int
    book_id: int
    borrow_date: datetime
    return_date: Optional[datetime] = None

    # ==========================================
    # 关系嵌套 (给前端提供极大便利)
    # ==========================================
    # 直接嵌套完整的 Book 和 User 信息
    book: BookResponse
    user: UserResponse

    model_config = ConfigDict(from_attributes=True)
  1. 为什么没有 BorrowCreate?

    • 你的需求文档中,借书接口是 POST /{book_id}。图书 ID 通过 Path 参数传,用户 ID 通过 JWT Token 解析获得。所以借书根本不需要前端传 Body 过来,自然就不需要创建模型。
  2. 关系嵌套:

    • 这里的 book: BookResponse 和 user: UserResponse 是 Pydantic 的嵌套模型。

    • 当你在路由返回 BorrowRecord 的 ORM 对象时,只要提前用 selectinload 把 book 和 user 查出来,Pydantic 就会自动把它们组装成嵌套的 JSON 返回给前端。前端可以直接渲染 record.book.title 和 record.user.username。

2、4.收尾

复制代码
from .user import UserBase, UserCreate, UserLogin, UserResponse
from .token import Token, TokenData
from .book import BookBase, BookCreate, BookUpdate, BookResponse
from .borrow import BorrowRecordResponse

__all__ = [
    "UserBase", "UserCreate", "UserLogin", "UserResponse",
    "Token", "TokenData",
    "BookBase", "BookCreate", "BookUpdate", "BookResponse",
    "BorrowRecordResponse"
]

3、业务逻辑核心

编写 CRUD 时 因为使用了 AsyncSession,所有的查询和提交都必须加上 await。

3、1.系统安全core/security.py

在这个文件里,我们要实现三个核心功能:密码加密 、密码校验 、以及 JWT Token 的生成与解析。

3、1.1*安装安全依赖包

pip install "passlibbcrypt" "python-josecryptography"

(说明:passlib 用于处理密码哈希,python-jose 用于生成和解析 JWT Token)

3、1.2*core/security.py
复制代码
from datetime import datetime, timedelta, timezone
from typing import Any, Union, Optional

from jose import jwt, JWTError
from passlib.context import CryptContext

# ==========================================
# 1. 安全配置常量 (实际生产环境建议写在 .env 文件里)
# ==========================================
# 用于 JWT 签名的密钥,必须绝对保密。可以使用 openssl rand -hex 32 生成
SECRET_KEY = "your-super-secret-key-please-change-it-in-production-2024"
# 加密算法
ALGORITHM = "HS256"
# Token 过期时间(分钟),这里设为 60 分钟
ACCESS_TOKEN_EXPIRE_MINUTES = 60

# ==========================================
# 2. 密码哈希配置
# ==========================================
# 使用 bcrypt 算法,自动处理盐值(salt)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """
    校验明文密码和数据库里的哈希密码是否匹配
    """
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """
    将用户注册时的明文密码转换成 bcrypt 哈希值
    """
    return pwd_context.hash(password)

# ==========================================
# 3. JWT Token 生成与解析
# ==========================================
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
    """
    生成 JWT Token
    :param subject: 一般是用户的唯一标识,比如 username 或 user_id
    :param expires_delta: 自定义过期时间,如果不传则使用默认时间
    """
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    
    # 载荷 payload,可以塞入非敏感信息
    to_encode = {"exp": expire, "sub": str(subject)}
    
    # 使用 SECRET_KEY 和 HS256 算法进行签名
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

def decode_access_token(token: str) -> Optional[str]:
    """
    解析 JWT Token,提取出 subject (也就是 username)
    如果 Token 无效或已过期,返回 None
    """
    try:
        # 解码并校验签名
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        # sub 是我们在生成时塞进去的标识
        subject: str = payload.get("sub")
        if subject is None:
            return None
        return subject
    except JWTError:
        # 捕获 JWT 解析错误(比如过期、被篡改)
        return None
  1. 为什么用 bcrypt 而不是 MD5?

    • MD5 已经被证明不安全(容易被彩虹表撞库破解)。bcrypt 自带"加盐"功能,且计算速度故意设计得很慢,黑客无法通过暴力破解。而且,每次对同一个密码哈希出来的结果都不一样,但 verify 方法总能正确匹配。
  2. timezone.utc 的运用 :JWT 的 exp 字段必须是 UTC 时间。这里使用了带时区的 timezone.utc,完美避免了服务器所在时区带来的时间偏差问题。

  3. sub 字段 :JWT 标准规定,sub (Subject) 用于存储用户的唯一标识。后面我们在依赖注入里,只要解析出这个 sub,就能知道当前是哪个用户登录了。

  4. 为什么 decode 返回 Optional[str]? 如果 Token 过期了,或者别人伪造了一个 Token,jwt.decode 会抛出 JWTError,我们捕获它并返回 None,方便后续依赖项判断是否抛出 401 异常。

3、2.拦截请求、判断用户是否登录**routers/deps.py**

复制代码
"""
OAuth2PasswordBearer:

它自动帮我们从 HTTP 请求头里提取 Authorization: Bearer <token> 中的那串 Token。

当你在 Swagger UI (/docs) 页面测试时,点击右上角的"Authorize"按钮,它就能自动把 Token 塞进后续的请求头里。

Annotated:这是 FastAPI 推荐的现代写法。Annotated[AsyncSession, Depends(get_db)] 比以前的 db: AsyncSession = Depends(get_db) 更清晰,且在后续代码中复用性更强。

get_current_admin 的巧妙之处:它依赖于 get_current_user。这意味着如果用户连登录都没登录(Token 无效),会直接抛出 401;如果登录了但只是个普通读者(is_admin=False),才会走到这里抛出 403。这完美契合了你需求文档中的 RBAC 权限设计(401 未授权 vs 403 禁止访问)。

scalar_one_or_none():这是 SQLAlchemy 2.0 异步查询的标准写法。它执行查询并返回单个 ORM 对象,如果没有找到则返回 None,避免了繁琐的 result[0] 或空列表报错。

"""


from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from db.database import get_db
from models.user import User
from core.security import decode_access_token

# 1. 定义 OAuth2 密码承载器
# tokenUrl 必须指向我们后续要写的登录接口。FastAPI 的 Swagger UI 会用它来自动生成"Authorize"按钮
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/users/login")

# 定义 401 错误的统一响应格式,符合 OAuth2 规范
credentials_exception = HTTPException(
    status_code=status.HTTP_401_UNAUTHORIZED,
    detail="无法验证凭据或 Token 已过期",
    headers={"WWW-Authenticate": "Bearer"},
)


async def get_current_user(
        token: Annotated[str, Depends(oauth2_scheme)],
        db: Annotated[AsyncSession, Depends(get_db)]
) -> User:
    """
    依赖注入:解析 Token,获取当前登录的 User 对象
    """
    # 1. 解析 Token,拿到 username
    username = decode_access_token(token)
    if username is None:
        raise credentials_exception

    # 2. 异步查询数据库,验证用户是否存在
    # 注意:这里必须用 await 和 execute
    stmt = select(User).where(User.username == username)
    result = await db.execute(stmt)
    user = result.scalar_one_or_none()

    if user is None:
        raise credentials_exception

    return user


async def get_current_admin(
        current_user: Annotated[User, Depends(get_current_user)]
) -> User:
    """
    依赖注入:检查当前登录用户是否为管理员
    如果普通用户尝试调用管理员接口,返回 403 Forbidden
    """
    if not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="权限不足,仅管理员可操作"
        )
    return current_user
  1. OAuth2PasswordBearer:

    • 它自动帮我们从 HTTP 请求头里提取 Authorization: Bearer <token> 中的那串 Token。

    • 当你在 Swagger UI (/docs) 页面测试时,点击右上角的"Authorize"按钮,它就能自动把 Token 塞进后续的请求头里。

  2. Annotated :这是 FastAPI 推荐的现代写法。Annotated[AsyncSession, Depends(get_db)] 比以前的 db: AsyncSession = Depends(get_db) 更清晰,且在后续代码中复用性更强。

  3. get_current_admin 的巧妙之处 :它依赖于 get_current_user。这意味着如果用户连登录都没登录(Token 无效),会直接抛出 401;如果登录了但只是个普通读者(is_admin=False),才会走到这里抛出 403。这完美契合了你需求文档中的 RBAC 权限设计(401 未授权 vs 403 禁止访问)。

  4. scalar_one_or_none() :这是 SQLAlchemy 2.0 异步查询的标准写法。它执行查询并返回单个 ORM 对象,如果没有找到则返回 None,避免了繁琐的 result[0] 或空列表报错。

3、3.CRUD

3、3.1*crud/user.py
复制代码
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional

from models.user import User
from schemas.user import UserCreate
from core.security import get_password_hash

async def get_user_by_username(db: AsyncSession, username: str) -> Optional[User]:
    """
    根据用户名查询用户
    """
    stmt = select(User).where(User.username == username)
    result = await db.execute(stmt)
    return result.scalar_one_or_none()

async def get_user_by_email(db: AsyncSession, email: str) -> Optional[User]:
    """
    根据邮箱查询用户
    """
    stmt = select(User).where(User.email == email)
    result = await db.execute(stmt)
    return result.scalar_one_or_none()

async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
    """
    根据用户 ID 查询用户
    """
    stmt = select(User).where(User.id == user_id)
    result = await db.execute(stmt)
    return result.scalar_one_or_none()

async def create_user(db: AsyncSession, user_in: UserCreate) -> User:
    """
    创建新用户(注册功能的核心)
    注意:这里需要传入经过 Pydantic 校验后的 UserCreate 对象
    """
    # 1. 对明文密码进行 bcrypt 哈希加密
    hashed_pwd = get_password_hash(user_in.password)
    
    # 2. 实例化 SQLAlchemy ORM 对象
    # 注意:前端传的是 password,数据库存的是 hashed_password
    db_user = User(
        username=user_in.username,
        email=user_in.email,
        hashed_password=hashed_pwd,
        is_admin=False  # 默认注册的用户都是普通读者
    )
    
    # 3. 添加到 session,并提交事务
    db.add(db_user)
    await db.commit()
    # 4. 刷新对象,从数据库获取最新的状态(主要是为了拿到自增的 id 和 created_at 时间)
    await db.refresh(db_user)
    
    return db_user
  1. await db.commit() 与 await db.refresh():

    • 在异步操作中,commit 是必须 await 的,它才会真正把数据发给 MySQL。

    • refresh 的作用是:因为我们在 Python 内存中 add 了这个对象,它的 id 和 created_at 还是空的。执行 refresh 后,SQLAlchemy 会立刻去数据库查一遍,把自增的 ID 和生成的时间填回这个对象里。

  2. get_password_hash 的复用 :CRUD 层不处理业务规则,只负责"存"。具体的加密算法细节全部藏在 core/security.py 中,这就是分层的魅力。

  3. Optional[User] :这也是现代 Python 的类型提示,告诉 IDE 和调用者,这个函数可能查不到人,会返回 None。

测试:
复制代码
# 我的业务逻辑层与路由层完全解耦,并且使用了异步 SQLAlchemy 2.0 规范,支持独立测试
# 在写 crud/borrow.py(借书、还书) 时,因为涉及并发锁(with_for_update)和库存扣减,这里必须写脚本测试。
# 比如模拟两个用户同时借一本书,看看库存会不会变成 -1。


# test_user_crud.py
import asyncio
from db.database import AsyncSessionLocal,engine
from schemas.user import UserCreate
import crud.user as user_crud


async def main():
    print("=== 开始测试 用户 CRUD ===")

    # 使用异步上下文管理器获取 session
    async with AsyncSessionLocal() as session:

        # 1. 构造注册数据
        test_user = UserCreate(
            username="test_reader",
            email="reader@example.com",
            password="securepassword123"
        )

        # 2. 测试注册
        # 先查一下有没有,防止重复运行报错
        exist_user = await user_crud.get_user_by_username(session, test_user.username)
        if exist_user:
            print(f"用户 {test_user.username} 已存在,跳过创建。")
        else:
            db_user = await user_crud.create_user(session, test_user)
            print(f"✅ 创建成功!用户 ID: {db_user.id}, 用户名: {db_user.username}")
            print(f"   数据库里的密码哈希: {db_user.hashed_password[:20]}...")

        # 3. 测试查询
        print("\n=== 测试查询 ===")
        queried_user = await user_crud.get_user_by_username(session, "test_reader")
        if queried_user:
            print(f"✅ 查询成功!查到的邮箱是: {queried_user.email}")
        else:
            print("❌ 查询失败,没找到用户。")


    await engine.dispose()


if __name__ == "__main__":
    asyncio.run(main())

报错就试一下

pip install "bcrypt==4.0.1"

3、3.2*crud/book.py

在这个文件里,我们将实现图书的增删改查,其中搜索、分页、ISBN 查重 以及删除前的业务拦截是核心亮点。

复制代码
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_, func
from typing import Optional, Sequence, Tuple

from models.book import Book
from models.borrow import BorrowRecord
from schemas.book import BookCreate, BookUpdate

async def get_book_by_id(db: AsyncSession, book_id: int) -> Optional[Book]:
    """根据 ID 查询单本图书"""
    stmt = select(Book).where(Book.id == book_id)
    result = await db.execute(stmt)
    return result.scalar_one_or_none()

async def get_book_by_isbn(db: AsyncSession, isbn: str) -> Optional[Book]:
    """根据 ISBN 查询图书(用于新增或修改时的唯一性校验)"""
    stmt = select(Book).where(Book.isbn == isbn)
    result = await db.execute(stmt)
    return result.scalar_one_or_none()

async def get_books(
    db: AsyncSession, 
    skip: int = 0, 
    limit: int = 10, 
    search: Optional[str] = None
) -> Tuple[Sequence[Book], int]:
    """
    获取图书列表(支持分页和关键词搜索)
    返回一个元组:(当前页的图书列表, 总数量)
    总数量用于前端实现分页器(比如显示"共 50 条")
    """
    # 1. 基础查询
    stmt = select(Book)
    
    # 2. 如果有搜索词,按书名或作者进行模糊匹配
    if search:
        stmt = stmt.where(or_(Book.title.ilike(f"%{search}%"), Book.author.ilike(f"%{search}%")))
        
    # 3. 查询总数量 (用于前端计算总页数)
    count_stmt = select(func.count()).select_from(stmt.subquery())
    total_result = await db.execute(count_stmt)
    total = total_result.scalar() or 0
    
    # 4. 应用分页并执行查询
    stmt = stmt.offset(skip).limit(limit).order_by(Book.id.desc()) # 默认按最新入库排序
    result = await db.execute(stmt)
    books = result.scalars().all()
    
    return books, total

async def create_book(db: AsyncSession, book_in: BookCreate) -> Book:
    """创建新图书"""
    db_book = Book(**book_in.model_dump()) # Pydantic v2 写法,直接将模型转为字典
    db.add(db_book)
    await db.commit()
    await db.refresh(db_book)
    return db_book

async def update_book(db: AsyncSession, db_book: Book, book_in: BookUpdate) -> Book:
    """
    更新图书信息
    注意:BookUpdate 中的所有字段都是 Optional 的
    """
    # exclude_unset=True 意味着:前端传了哪个字段,我就改哪个字段;没传的不动(保持原值)
    update_data = book_in.model_dump(exclude_unset=True)
    
    for field, value in update_data.items():
        setattr(db_book, field, value)
        
    await db.commit()
    await db.refresh(db_book)
    return db_book

async def delete_book(db: AsyncSession, db_book: Book) -> None:
    """
    删除图书
    亮点:在删除前,检查是否存在未归还的借阅记录。如果有,抛出异常阻止删除。
    """
    # 1. 检查是否有未归还的记录 (return_date 为 None)
    stmt = select(func.count()).select_from(BorrowRecord).where(
        BorrowRecord.book_id == db_book.id,
        BorrowRecord.return_date.is_(None)
    )
    result = await db.execute(stmt)
    active_borrows = result.scalar() or 0
    
    if active_borrows > 0:
        # 注意:CRUD 层通常不直接抛 HTTPException,我们抛 ValueError,让路由层去捕获转为 400 错误
        # 或者为了课设简单,直接在这里抛 HTTPException 也可以,但不符合严格的分层规范。
        raise ValueError(f"该图书还有 {active_borrows} 本未归还,无法删除!")
        
    # 2. 如果没有未归还的记录,执行删除
    await db.delete(db_book)
    await db.commit()
  1. or_ 与 ilike :or_ 实现了"搜索书名 或 作者"的逻辑;ilike 是大小写不敏感的模糊匹配(MySQL 中 like 默认不区分大小写,但 ilike 在不同数据库中兼容性更好)。

  2. Tuple[Sequence[Book], int] :对于带有分页的列表接口,仅仅返回一个 List[Book] 是不够的。前端需要知道"总共有多少本书"才能画出分页控件。所以这里同时返回了当前页的数据和 total 总数。

  3. exclude_unset=True :这是 BookUpdate 的绝佳拍档。如果前端只传了 {"price": 99.9},那么 update_data 里就只有 price。通过 setattr 循环,我们只会修改价格,而不会把 title 变成 None。

  4. ValueError 拦截 :在 delete_book 中,我们查了一下 borrow_records。如果有未归还的书,抛出 ValueError。这样做是为了保护数据完整性,防止管理员误删正在借阅中的书。在 Router 层,我们会捕获这个错误并返回 400 Bad Request。

测试:
复制代码
import asyncio
from db.database import AsyncSessionLocal, engine
from schemas.book import BookCreate, BookUpdate
import crud.book as book_crud

async def main():
    print("=== 开始测试 图书 CRUD ===")
    async with AsyncSessionLocal() as session:
        # 1. 测试创建图书
        test_book = BookCreate(
            title="Python 编程从入门到入土",
            author="张三",
            isbn="978-7-111-11111-1",
            price=59.9,
            stock=10
        )
        # 先查重
        exist = await book_crud.get_book_by_isbn(session, test_book.isbn)
        if not exist:
            book = await book_crud.create_book(session, test_book)
            print(f"✅ 创建成功!ID: {book.id}, 书名: {book.title}")
        else:
            book = exist
            print(f"已存在图书 {book.title}")

        # 2. 测试部分更新
        print("\n=== 测试部分更新 ===")
        update_data = BookUpdate(price=39.9) # 只改价格
        updated_book = await book_crud.update_book(session, book, update_data)
        print(f"✅ 更新成功!新价格: {updated_book.price}, 书名没变: {updated_book.title}")

        # 3. 测试分页与搜索
        print("\n=== 测试分页与搜索 ===")
        books, total = await book_crud.get_books(session, skip=0, limit=5, search="Python")
        print(f"✅ 搜索 'Python',找到 {total} 本书。当前页有 {len(books)} 本。")

    await engine.dispose()

if __name__ == "__main__":
    asyncio.run(main())
3、3.3*crud/borrow.py
复制代码
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from datetime import datetime
from typing import Sequence

from models.book import Book
from models.borrow import BorrowRecord

async def borrow_book(db: AsyncSession, user_id: int, book_id: int) -> BorrowRecord:
    """
    核心借书业务:包含并发防超卖、库存校验、重复借阅拦截
    """
    try:
        # 1. 核心重点:查询图书时加悲观锁 (with_for_update)
        # 这会在数据库层面锁住这行数据,如果有人正在借这本书,另一个人必须等待
        stmt = select(Book).where(Book.id == book_id).with_for_update()
        result = await db.execute(stmt)
        book = result.scalar_one_or_none()
        
        if not book:
            raise ValueError("图书不存在")
            
        # 2. 检查库存是否充足
        if book.stock <= 0:
            raise ValueError("库存不足,借阅失败")
            
        # 3. 检查用户是否已经借了这本书且还没还(防止重复借阅)
        exist_stmt = select(BorrowRecord).where(
            and_(
                BorrowRecord.user_id == user_id,
                BorrowRecord.book_id == book_id,
                BorrowRecord.return_date.is_(None)
            )
        )
        exist_result = await db.execute(exist_stmt)
        if exist_result.scalar_one_or_none():
            raise ValueError("您已经借阅了这本书且尚未归还,请勿重复借阅")

        # 4. 扣减库存
        book.stock -= 1
        
        # 5. 创建借阅记录
        db_record = BorrowRecord(
            user_id=user_id,
            book_id=book_id
        )
        db.add(db_record)
        
        # 6. 提交事务(解除悲观锁)
        await db.commit()
        await db.refresh(db_record)
        
        return db_record
        
    except Exception as e:
        # 如果发生任何异常(库存不足、重复借阅、数据库报错),回滚整个事务
        await db.rollback()
        raise e


async def return_book(db: AsyncSession, user_id: int, record_id: int) -> BorrowRecord:
    """
    核心还书业务:归还时间更新、库存恢复
    """
    try:
        # 1. 查询借阅记录,并预加载图书信息
        stmt = select(BorrowRecord).options(selectinload(BorrowRecord.book)).where(BorrowRecord.id == record_id)
        result = await db.execute(stmt)
        record = result.scalar_one_or_none()
        
        if not record:
            raise ValueError("借阅记录不存在")
            
        # 2. 权限校验:只能还自己借的书
        if record.user_id != user_id:
            raise ValueError("无权操作他人的借阅记录")
            
        # 3. 状态校验:是否已经归还过了
        if record.return_date is not None:
            raise ValueError("该图书已经归还过了")
            
        # 4. 更新归还时间
        record.return_date = datetime.utcnow()
        
        # 5. 恢复库存
        record.book.stock += 1
        
        # 6. 提交事务
        await db.commit()
        await db.refresh(record)
        
        return record
        
    except Exception as e:
        await db.rollback()
        raise e


async def get_user_borrow_records(db: AsyncSession, user_id: int) -> Sequence[BorrowRecord]:
    """
    获取某个用户的所有借阅记录(包含书籍详情)
    """
    stmt = (
        select(BorrowRecord)
        .options(
            selectinload(BorrowRecord.book),
            selectinload(BorrowRecord.user)
        )
        .where(BorrowRecord.user_id == user_id)
        .order_by(BorrowRecord.borrow_date.desc())
    )
    result = await db.execute(stmt)
    return result.scalars().all()
  1. with_for_update()(悲观锁) :这是防止"超卖"的终极武器。假设库存只有 1 本,A 用户和 B 用户在同一毫秒点击借书。数据库收到两个请求,如果是 A 先拿到锁,B 就会被阻塞挂起,直到 A 扣完库存并提交事务,B 才能继续,此时 B 读到的 stock 已经是 0,直接抛出"库存不足",完美避开超卖。

  2. async with db.begin(): :这是 SQLAlchemy 2.0 异步提供的事务上下文。如果在 begin 块内发生任何异常(比如 raise ValueError),数据库会自动回滚(Rollback),保证 book.stock 绝对不会被错误扣减。

  3. await db.flush() :在事务还没 commit 之前,把 SQL 发给数据库,这样我们可以拿到 db_record.id。因为外面有 begin() 包裹,所以真正的提交是在块结束时自动完成的。

  4. selectinload :在异步里,直接访问 record.book 会报错。selectinload(BorrowRecord.book) 会在查询记录时,额外发一条 IN 的 SQL 把对应的书一起查出来,完美解决异步懒加载问题。

测试:
复制代码
import asyncio
from db.database import AsyncSessionLocal, engine
import crud.borrow as borrow_crud
import crud.user as user_crud
import crud.book as book_crud

async def main():
    print("=== 开始测试 借阅核心业务 ===")
    async with AsyncSessionLocal() as session:
        user = await user_crud.get_user_by_username(session, "test_reader")
        book = await book_crud.get_book_by_isbn(session, "978-7-111-11111-1")
        
        if not user or not book:
            print("❌ 请先运行之前的测试生成数据")
            return

        # 提前提取出普通变量,防止 rollback 后对象过期触发懒加载
        user_id = user.id
        book_id = book.id
        book_stock_before = book.stock

        print(f"当前用户: {user.username}, 图书: {book.title}, 当前库存: {book_stock_before}")

        # 1. 测试借书
        record_id = None
        try:
            record = await borrow_crud.borrow_book(session, user_id, book_id)
            record_id = record.id  # 提取 ID
            print(f"✅ 借书成功!记录 ID: {record_id}")
        except ValueError as e:
            print(f"❌ 借书失败: {e}")

        # 2. 测试重复借同一本书 (应该被拦截,并触发 rollback)
        try:
            await borrow_crud.borrow_book(session, user_id, book_id)
            print("❌ 测试失败:重复借书没被拦截!")
        except ValueError as e:
            print(f"✅ 成功拦截重复借书: {e}")

        # 验证库存(因为 rollback,需要重新查一下书)
        book_check = await book_crud.get_book_by_id(session, book_id)
        print(f"当前库存 (应为 {book_stock_before - 1}): {book_check.stock}")

        # 3. 测试还书
        try:
            returned_record = await borrow_crud.return_book(session, user_id, record_id)
            print(f"✅ 还书成功!归还时间: {returned_record.return_date}")
        except ValueError as e:
            print(f"❌ 还书失败: {e}")

        # 4. 测试重复还书 (应该被拦截)
        try:
            await borrow_crud.return_book(session, user_id, record_id)
            print("❌ 测试失败:重复还书没被拦截!")
        except ValueError as e:
            print(f"✅ 成功拦截重复还书: {e}")

        # 最终库存验证
        book_final = await book_crud.get_book_by_id(session, book_id)
        print(f"最终库存 (应为 {book_stock_before}): {book_final.stock}")

    await engine.dispose()

if __name__ == "__main__":
    asyncio.run(main())

3、4.接口层(routers)

一、 接口总览与职责划分

1. 用户模块 (routers/user.py) -> 前缀 /api/users

  • POST /register:注册接口。工作 :接收 UserCreate,查重,调用 crud.create_user。

  • POST /login:登录接口。工作:接收用户名密码,校验密码,生成 JWT Token 返回。

    • 注意:为了让 Swagger UI 的 Authorize 按钮生效,这里我们需要使用 FastAPI 的 OAuth2PasswordRequestForm 来接收表单数据,而不是纯 JSON。
  • GET /me:获取个人信息。工作 :依赖 get_current_user,直接返回当前用户对象。

2. 图书模块 (routers/books.py) -> 前缀 /api/books

  • GET /:获取图书列表。工作 :接收分页和搜索参数,调用 crud.get_books。需要自定义一个包含 total 的返回结构。

  • GET /{book_id}:获取图书详情。工作:根据 ID 查,查不到返回 404。

  • POST /:新增图书。工作 :依赖 get_current_admin 拦截非管理员。接收 BookCreate,校验 ISBN 唯一性,调用 crud.create_book。

  • PUT /{book_id}:修改图书。工作 :依赖 get_current_admin。接收 BookUpdate,调用 crud.update_book。

  • DELETE /{book_id}:删除图书。工作 :依赖 get_current_admin。调用 crud.delete_book,捕获它抛出的 ValueError(有未归还记录),转为 400 错误。

3. 借阅模块 (routers/borrows.py) -> 前缀 /api/borrows

  • POST /{book_id}:借阅图书。工作 :依赖 get_current_user。调用 crud.borrow_book,捕获 ValueError 转为 400。

  • POST /{record_id}/return:归还图书。工作 :依赖 get_current_user。调用 crud.return_book,捕获 ValueError 转为 400。

  • GET /my:获取我的借阅记录(需求文档没写,但必须有,否则用户不知道自己借了啥)。工作 :依赖 get_current_user,调用 crud.get_user_borrow_records。


二、 Router 层必须处理的 4 个核心工作

  1. 依赖注入 (Depends):

    • 需要用到我们写好的 get_db。

    • 需要用到 get_current_user 和 get_current_admin 来控制权限(拦截 401 和 403)。

  2. 异常捕获与转换:

    • 我们的 crud 层抛出的是纯 Python 的 ValueError。在 Router 层,必须用 try...except ValueError as e: 捕获它,并转换成 FastAPI 的 HTTPException(status_code=400, detail=str(e)),这样前端才能收到友好的报错提示。
  3. 响应模型 (Response Model):

    • 利用 FastAPI 的 response_model=UserResponse 参数,直接返回 ORM 对象,FastAPI 会自动调用 Pydantic 帮你过滤敏感字段(比如密码)并转成 JSON。
  4. 分页响应的结构:

    • 图书列表需要返回 total。我们需要在 schemas/book.py 里补一个 PaginatedBooks 模型:

      python

      复制代码
      class PaginatedBooks(BaseModel):
          total: int
          items: list[BookResponse]

三、 路由挂载规划(在 main.py 中)

  • users.router -> prefix="/api/users"

  • books.router -> prefix="/api/books"

  • borrows.router -> prefix="/api/borrows"

3、4.1*routers/user.py

这个文件是整个系统的门户,负责注册、登录(颁发 Token)以及获取当前用户信息。

复制代码
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated

from db.database import get_db
from schemas.user import UserCreate, UserResponse
from schemas.token import Token
from crud import user as user_crud
from core.security import verify_password, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES
from routers.deps import get_current_user

# 创建路由实例
# tags 用于在 Swagger UI 文档中分组显示
router = APIRouter(tags=["用户模块"])

@router.post("/register", response_model=UserResponse, summary="用户注册")
async def register(
    user_in: UserCreate, 
    db: Annotated[AsyncSession, Depends(get_db)]
):
    """
    注册新用户:
    - **username**: 唯一用户名
    - **email**: 合法邮箱,唯一
    - **password**: 至少 6 位
    """
    # 1. 检查用户名是否已存在
    user_by_name = await user_crud.get_user_by_username(db, username=user_in.username)
    if user_by_name:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, 
            detail="该用户名已被注册"
        )
        
    # 2. 检查邮箱是否已存在
    user_by_email = await user_crud.get_user_by_email(db, email=user_in.email)
    if user_by_email:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST, 
            detail="该邮箱已被注册"
        )
        
    # 3. 调用 CRUD 创建用户(内部会自动进行 bcrypt 密码哈希)
    new_user = await user_crud.create_user(db, user_in=user_in)
    
    # 因为配置了 response_model=UserResponse,这里直接返回 ORM 对象即可
    # FastAPI 会自动提取需要的字段(过滤掉密码哈希)
    return new_user


@router.post("/login", response_model=Token, summary="用户登录 (获取 Token)")
async def login(
    # 注意:这里使用的是 OAuth2PasswordRequestForm,它接收的是表单数据 (form-data)
    # 而不是 JSON。这是为了配合 FastAPI 的 Swagger UI Authorize 按钮。
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: Annotated[AsyncSession, Depends(get_db)]
):
    """
    登录以获取 JWT Access Token。
    - **username**: 用户名
    - **password**: 密码
    """
    # 1. 根据用户名查找用户
    user = await user_crud.get_user_by_username(db, username=form_data.username)
    
    # 2. 校验用户是否存在,以及密码是否正确
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
        
    # 3. 生成 JWT Token
    # 我们将用户的 username 作为 subject 存入 Token
    access_token = create_access_token(subject=user.username)
    
    # 4. 返回符合 OAuth2 规范的 Token 格式
    return {"access_token": access_token, "token_type": "bearer"}


@router.get("/me", response_model=UserResponse, summary="获取当前登录用户信息")
async def read_users_me(
    # 这里的 get_current_user 依赖会自动解析请求头里的 Token,并查出当前 User
    current_user: Annotated[UserResponse, Depends(get_current_user)]
):
    """
    获取当前登录用户的信息。
    需要在请求头中携带 Token: `Authorization: Bearer <your_token>`
    """
    return current_user
  1. OAuth2PasswordRequestForm:

    • 你可能会疑惑,为什么登录接口不用 JSON(UserLogin),而是用表单(form_data)?

    • 这是 FastAPI 的官方设计:只要你使用了 OAuth2PasswordRequestForm,Swagger UI (/docs) 右上角就会自动出现一个 Authorize 按钮。你填入用户名密码登录后,后续所有的请求都会自动带上 Token,测试极其方便。

  2. response_model=UserResponse:

    • 这是数据防泄漏的终极手段。就算你的 new_user 对象里有 hashed_password,只要声明了 response_model,FastAPI 就会像漏斗一样,只漏出 UserResponse 里定义的字段。
  3. 异常抛出:

    • 我们统一使用了 HTTPException。比如邮箱重复返回 400;登录失败返回 401(未授权)。

    • 另外,注意 get_current_user 作为依赖项注入在 read_users_me 里,一旦 Token 不对,它会自动在底层抛出 401 拦截,根本不会进入我们的函数体。

3、4.2*routers/books.py

这个文件不仅包含了图书的增删改查,还完美嵌入了管理员权限拦截(RBAC) 、分页搜索 以及业务异常捕获。

复制代码
# 这里我们需要处理 管理员权限拦截(RBAC 鉴权)和 分页返回。
# 我们要先在 schemas/book.py 里补一个分页模型。请去 schemas/book.py
"""
# schemas/book.py 追加
class PaginatedBooks(BaseModel):
    total: int = Field(..., description="总图书数量")
    items: list[BookResponse] = Field(..., description="当前页的图书列表")
"""
复制代码
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional

from db.database import get_db
from schemas.book import BookCreate, BookUpdate, BookResponse, PaginatedBooks
from crud import book as book_crud
from routers.deps import get_current_admin

router = APIRouter(tags=["图书模块"])

@router.get("/", response_model=PaginatedBooks, summary="获取图书列表 (支持搜索和分页)")
async def read_books(
    db: Annotated[AsyncSession, Depends(get_db)],
    skip: int = Query(0, ge=0, description="跳过多少条(偏移量)"),
    limit: int = Query(10, ge=1, le=100, description="每页数量"),
    search: Optional[str] = Query(None, description="搜索关键词(匹配书名或作者)")
):
    """
    获取图书列表(公开接口,游客也可访问)。
    - 支持 `search` 进行书名或作者模糊搜索。
    - 支持 `skip` 和 `limit` 分页。
    """
    books, total = await book_crud.get_books(db, skip=skip, limit=limit, search=search)
    return {"total": total, "items": books}


@router.get("/{book_id}", response_model=BookResponse, summary="获取单本图书详情")
async def read_book(
    book_id: int,
    db: Annotated[AsyncSession, Depends(get_db)]
):
    """
    根据图书 ID 获取详情(公开接口)。
    """
    book = await book_crud.get_book_by_id(db, book_id=book_id)
    if not book:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图书不存在")
    return book


@router.post("/", response_model=BookResponse, summary="新增图书 (仅管理员)")
async def create_book(
    book_in: BookCreate,
    db: Annotated[AsyncSession, Depends(get_db)],
    # 注入管理员权限拦截器
    admin: Annotated[dict, Depends(get_current_admin)]
):
    """
    新增一本图书。
    - **权限**: 仅限管理员 (is_admin=True)
    """
    # 1. 检查 ISBN 是否重复
    exist_book = await book_crud.get_book_by_isbn(db, isbn=book_in.isbn)
    if exist_book:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=f"ISBN 为 {book_in.isbn} 的图书已存在"
        )
        
    # 2. 创建图书
    new_book = await book_crud.create_book(db, book_in=book_in)
    return new_book


@router.put("/{book_id}", response_model=BookResponse, summary="修改图书信息 (仅管理员)")
async def update_book(
    book_id: int,
    book_in: BookUpdate,
    db: Annotated[AsyncSession, Depends(get_db)],
    admin: Annotated[dict, Depends(get_current_admin)]
):
    """
    修改图书信息(支持部分修改)。
    - **权限**: 仅限管理员
    """
    # 1. 检查图书是否存在
    db_book = await book_crud.get_book_by_id(db, book_id=book_id)
    if not db_book:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图书不存在")
        
    # 2. 如果修改了 ISBN,需要检查新的 ISBN 是否与其他书冲突
    if book_in.isbn and book_in.isbn != db_book.isbn:
        exist_book = await book_crud.get_book_by_isbn(db, isbn=book_in.isbn)
        if exist_book:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST,
                detail=f"ISBN 为 {book_in.isbn} 的图书已存在"
            )
            
    # 3. 执行更新
    updated_book = await book_crud.update_book(db, db_book=db_book, book_in=book_in)
    return updated_book


@router.delete("/{book_id}", summary="删除图书 (仅管理员)")
async def delete_book(
    book_id: int,
    db: Annotated[AsyncSession, Depends(get_db)],
    admin: Annotated[dict, Depends(get_current_admin)]
):
    """
    删除图书。
    - **权限**: 仅限管理员
    - **注意**: 如果该书还有未归还的借阅记录,将会拦截并返回 400 错误。
    """
    # 1. 检查图书是否存在
    db_book = await book_crud.get_book_by_id(db, book_id=book_id)
    if not db_book:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图书不存在")
        
    # 2. 执行删除,捕获 CRUD 层抛出的业务异常
    try:
        await book_crud.delete_book(db, db_book=db_book)
    except ValueError as e:
        # 将 Python 的 ValueError 转换成 FastAPI 的 HTTP 400 错误
        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
        
    return {"message": "删除成功"}
  1. Query(...) 参数校验:

    • 在 read_books 中,我们使用了 skip: int = Query(0, ge=0)。这里的 ge=0 意味着如果前端传了负数(比如 ?skip=-1),FastAPI 会直接拦截并返回 422 错误。这比你手写 if skip < 0 优雅得多。
  2. get_current_admin 的神奇之处:

    • 在 create_book、update_book、delete_book 中,注入 admin: Annotated[dict, Depends(get_current_admin)]。

    • 这意味着,如果未登录,请求根本进不来,直接 401;如果登录了但只是读者,直接 403。拦截逻辑完全封装在底层,接口代码非常干净。

  3. 错误捕获与转换:

    • 还记得我们在 crud/book.py 里写过:如果有未归还的借阅,会抛出 ValueError 吗?

    • 在 delete_book 中,我们用 try...except ValueError as e 捕获了它,并 raise HTTPException(400, detail=str(e))。这样前端收到的就是友好的 400 报错,而不是 500 服务器内部错误。

3、4.3*routers/borrows.py

这个文件负责将我们之前写好的防超卖、事务控制等硬核业务逻辑暴露成 HTTP 接口,并利用依赖注入确保只有登录用户才能操作。

复制代码
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, List

from db.database import get_db
from schemas.borrow import BorrowRecordResponse
from models.user import User
from crud import borrow as borrow_crud
from routers.deps import get_current_user

router = APIRouter(tags=["借阅模块"])

@router.get("/my", response_model=List[BorrowRecordResponse], summary="获取我的借阅记录")
async def get_my_borrow_records(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_user)]
):
    """
    查看当前登录用户的所有借阅记录(包含书籍详情和借阅状态)。
    """
    records = await borrow_crud.get_user_borrow_records(db, user_id=current_user.id)
    return records


@router.post("/{book_id}", response_model=BorrowRecordResponse, summary="借阅图书")
async def borrow_book(
    book_id: int,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_user)]
):
    """
    借阅指定的图书。
    - **权限**: 登录用户
    - **注意**: 如果库存不足或已重复借阅未还,将返回 400 错误。
    """
    try:
        # 调用 CRUD 层核心业务逻辑(包含事务与悲观锁)
        record = await borrow_crud.borrow_book(db, user_id=current_user.id, book_id=book_id)
        return record
    except ValueError as e:
        # 捕获 CRUD 层抛出的业务异常(库存不足、已借未还等)
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(e)
        )


@router.post("/{record_id}/return", response_model=BorrowRecordResponse, summary="归还图书")
async def return_book(
    record_id: int,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_user)]
):
    """
    归还指定的借阅记录。
    - **权限**: 登录用户
    - **注意**: 只能归还自己的借阅记录,且不能重复归还。
    """
    try:
        # 调用 CRUD 层核心业务逻辑
        record = await borrow_crud.return_book(db, user_id=current_user.id, record_id=record_id)
        return record
    except ValueError as e:
        # 捕获 CRUD 层抛出的业务异常(记录不存在、无权操作、已归还等)
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(e)
        )
  1. 路径顺序的讲究:

    • 注意看,我把 GET /my 放在了 POST /{book_id} 前面。

    • 虽然它们一个是 GET 一个是 POST,在 FastAPI 里不会直接冲突,但这是好习惯。如果未来前端发个 GET /my,FastAPI 会优先匹配到无参数的固定路径,避免把它误认为 book_id="my" 导致类型报错。

  2. 极简的权限控制:

    • 在三个接口中,我们都注入了 current_user: Annotated[User, Depends(get_current_user)]。

    • 只要用户带着有效的 Token,current_user 就会自动变成当前登录用户的实例。我们直接把 current_user.id 传给 CRUD 层,彻底避免了前端伪造 user_id 越权操作的风险。

  3. 统一的异常捕获:

    • 你的 CRUD 层把所有的业务错误(库存不足、重复借阅、无权还书)都用 ValueError 抛出来。

    • 路由层像一张大网,用 try...except ValueError 全部网住,然后统一转换为 HTTP 400。前端只需看 HTTP 状态码和 detail 里的中文提示即可。

3、4.4*组装main.py

把所有路由挂载到 FastAPI 实例上,并配置好跨域和启动清理逻辑。

复制代码
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from db.database import engine
from routers import user, books, borrows

# 1. 定义生命周期事件(可选,但强烈推荐)
# 用于在应用关闭时,断开数据库连接池,防止终端出现 "Event loop is closed" 警告
@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await engine.dispose()
    print("数据库连接池已关闭")

# 2. 创建 FastAPI 实例
app = FastAPI(
    title="图书管理系统 API",
    description="基于 FastAPI + SQLAlchemy 异步的图书管理系统课设",
    version="1.0.0",
    lifespan=lifespan
)

# 3. 配置跨域(CORS)
# 如果你的前端是本地开发的 Vue/React,这一步是必须的,否则浏览器会拦截请求
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 课设为了方便允许所有,生产环境请写具体域名
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 4. 挂载所有路由
app.include_router(user.router, prefix="/api/users")
app.include_router(books.router, prefix="/api/books")
app.include_router(borrows.router, prefix="/api/borrows")

@app.get("/", tags=["根路径"])
def read_root():
    return {"message": "欢迎使用图书管理系统 API,请访问 /docs 查看接口文档"}

4、全链路测试

第一步:注册普通用户 (POST /api/users/register)

  1. 在 Swagger UI 页面,展开 POST /api/users/register。

  2. 点击右侧的 Try it out 按钮。

  3. 在 Request body 框里输入以下 JSON:

    json

    复制代码
    {
      "username": "student001",
      "email": "student001@example.com",
      "password": "password123"
    }
  4. 点击 Execute。

  5. 预期结果 :Server response 的 Code 应该是 200,返回的 JSON 里包含 id、username、email、is_admin: false,并且绝对没有 password 字段。


第二步:登录获取 Token (POST /api/users/login)

  1. 滚动到页面最上方,找到右上角的 Authorize 按钮(带个小锁图标),点击它。

  2. 会弹出一个登录框,输入:

    • username : student001

    • password : password123

      (注意:这里是表单输入,不需要写 JSON 的引号和花括号)

  3. 点击 Authorize ,如果成功,按钮上的小锁会变成"上锁"状态,点击 Close 。

    (此时你的浏览器已经记住了 Token,后续的所有请求都会自动带上 Authorization: Bearer xxx)


第三步:测试权限拦截 (报错 403)

  1. 滚动到图书模块,展开 POST /api/books/ 新增图书接口。

  2. 点击 Try it out。

  3. 输入测试图书 JSON:

    json

    复制代码
    {
      "title": "三体",
      "author": "刘慈欣",
      "isbn": "9787536692930",
      "price": 23.0,
      "stock": 5
    }
  4. 点击 Execute。

  5. 预期结果 :Server response 的 Code 应该是 403 Forbidden,并且 detail 显示 "权限不足,仅管理员可操作"。

    (这证明你的 RBAC 权限拦截非常完美!)


第四步:进数据库"提拔"自己为管理员

  1. 打开你的数据库管理工具(Navicat / DBeaver / MySQL 命令行)。

  2. 找到 library_db 库下的 users 表。

  3. 找到 student001 这行数据,把 is_admin 字段的值从 0 改成 1,保存。

  4. 回到 Swagger UI,重新点击右上角 Authorize ,再次输入用户名密码登录。

    (为什么要重新登录?虽然你的代码每次都会查库,但养成修改权限后重新登录的习惯能防止 Token 缓存问题)


第五步:再次新增图书 (成功)

  1. 重复第三步 的操作,再次在 POST /api/books/ 里输入"三体"的数据,点击 Execute。

  2. 预期结果 :Code 为 200,返回创建成功的图书对象,包含 id: 1。

    (去数据库里看一眼,books 表里是不是多了一本书)


第六步:借书 (POST /api/borrows/{book_id})

  1. 展开 POST /api/borrows/{book_id} 借阅接口。

  2. 点击 Try it out ,在 book_id 输入框填入刚才生成的图书 ID:1。

  3. 点击 Execute。

  4. 预期结果 :Code 为 200,返回一条借阅记录,里面有 id(比如 1),borrow_date 有值,return_date 为 null。

    (去数据库看一眼,books 表里"三体"的 stock 应该变成了 4)

  1. 在crud/borrow.py中

  2. 你的 borrow_book 接口定义了返回模型 response_model=BorrowRecordResponse。

  3. BorrowRecordResponse 里面嵌套了 book: BookResponse 和 user: UserResponse。

  4. 当 FastAPI 拿到你返回的 record 对象,准备组装成 JSON 给前端时,它需要去读取 record.book.title 等信息。

  5. 但是!你的 crud.borrow_book 函数在 commit 后,只返回了一个单纯的借阅记录,没有提前把 book 和 user 查出来。

  6. 于是 FastAPI 在异步环境中试图"偷偷"去数据库查书的信息,结果触发了异步禁止的懒加载,直接报错。

复制代码
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy.orm import selectinload
from datetime import datetime
from typing import Sequence

from models.book import Book
from models.borrow import BorrowRecord

async def borrow_book(db: AsyncSession, user_id: int, book_id: int) -> BorrowRecord:
    """
    核心借书业务:包含并发防超卖、库存校验、重复借阅拦截
    """
    try:
        # 1. 核心重点:查询图书时加悲观锁 (with_for_update)
        # 这会在数据库层面锁住这行数据,如果有人正在借这本书,另一个人必须等待
        stmt = select(Book).where(Book.id == book_id).with_for_update()
        result = await db.execute(stmt)
        book = result.scalar_one_or_none()
        
        if not book:
            raise ValueError("图书不存在")
            
        # 2. 检查库存是否充足
        if book.stock <= 0:
            raise ValueError("库存不足,借阅失败")
            
        # 3. 检查用户是否已经借了这本书且还没还(防止重复借阅)
        exist_stmt = select(BorrowRecord).where(
            and_(
                BorrowRecord.user_id == user_id,
                BorrowRecord.book_id == book_id,
                BorrowRecord.return_date.is_(None)
            )
        )
        exist_result = await db.execute(exist_stmt)
        if exist_result.scalar_one_or_none():
            raise ValueError("您已经借阅了这本书且尚未归还,请勿重复借阅")

        # 4. 扣减库存
        book.stock -= 1
        
        # 5. 创建借阅记录
        db_record = BorrowRecord(
            user_id=user_id,
            book_id=book_id
        )
        db.add(db_record)
        
        # 6. 提交事务(解除悲观锁)
        await db.commit()
        # --- 关键修复点 ---
        # 重新查询刚刚生成的记录,并一次性加载 book 和 user
        new_record_stmt = select(BorrowRecord).options(
            selectinload(BorrowRecord.book),
            selectinload(BorrowRecord.user)
        ).where(BorrowRecord.id == db_record.id)
        new_record_result = await db.execute(new_record_stmt)
        return new_record_result.scalar_one()
        
    except Exception as e:
        # 如果发生任何异常(库存不足、重复借阅、数据库报错),回滚整个事务
        await db.rollback()
        raise e


async def return_book(db: AsyncSession, user_id: int, record_id: int) -> BorrowRecord:
    """
    核心还书业务:归还时间更新、库存恢复
    """
    try:
        # 1. 查询借阅记录,并预加载图书信息
        # --- 关键修复点 ---
        # 查记录时,把 book 和 user 一起预加载出来
        stmt = select(BorrowRecord).options(
            selectinload(BorrowRecord.book),
            selectinload(BorrowRecord.user)  # 增加加载 user
        ).where(BorrowRecord.id == record_id)
        result = await db.execute(stmt)
        record = result.scalar_one_or_none()
        
        if not record:
            raise ValueError("借阅记录不存在")
            
        # 2. 权限校验:只能还自己借的书
        if record.user_id != user_id:
            raise ValueError("无权操作他人的借阅记录")
            
        # 3. 状态校验:是否已经归还过了
        if record.return_date is not None:
            raise ValueError("该图书已经归还过了")
            
        # 4. 更新归还时间
        record.return_date = datetime.utcnow()
        
        # 5. 恢复库存
        record.book.stock += 1
        
        # 6. 提交事务
        await db.commit()

        # 因为 expire_on_commit=False,commit 后对象不会过期,直接返回即可
        return record

        
    except Exception as e:
        await db.rollback()
        raise e


async def get_user_borrow_records(db: AsyncSession, user_id: int) -> Sequence[BorrowRecord]:
    """
    获取某个用户的所有借阅记录(包含书籍详情)
    """
    stmt = (
        select(BorrowRecord)
        .options(
            selectinload(BorrowRecord.book),
            selectinload(BorrowRecord.user)
        )
        .where(BorrowRecord.user_id == user_id)
        .order_by(BorrowRecord.borrow_date.desc())
    )
    result = await db.execute(stmt)
    return result.scalars().all()

第七步:查记录 (GET /api/borrows/my)

  1. 展开 GET /api/borrows/my 获取我的借阅记录接口。

  2. 点击 Try it out ,直接点击 Execute(不需要传任何参数)。

  3. 预期结果 :返回一个 JSON 数组。里面的 book 字段应该嵌套了"三体"的详细信息(书名、作者等),user 字段应该嵌套了 student001 的信息。

    (这证明我们的 selectinload 预加载和 Pydantic 嵌套模型完美生效!)


第八步:还书 (POST /api/borrows/{record_id}/return)

  1. 展开还书接口 POST /api/borrows/{record_id}/return。

  2. 点击 Try it out ,在 record_id 填刚才查出来的借阅记录 ID(比如 1)。

  3. 点击 Execute。

  4. 预期结果 :Code 为 200,返回的 JSON 里 return_date 变成了当前时间。

    (去数据库看一眼,books 表里"三体"的 stock 变回了 5)

5、打包项目

针对 Python 后端项目,"打包"通常包含三个层面的工作:

5、1.生成依赖清单 (requirements.txt)

在项目根目录 (并确保你激活了 fastapi_venv 虚拟环境),运行:

pip freeze > requirements.txt

5、2.清理项目垃圾(删掉临时文件、缓存、测试脚本等)

检查并删除 以下文件/文件夹(如果是完整的项目,建议写一个 .gitignore 文件,但课设直接手动删即可):

  1. 删除虚拟环境 :my_env/、fastapi_venv/、venv/ 等(千万别打包这个,别人没法用)。

  2. 删除 Python 缓存 :所有名为 __pycache__ 的文件夹。你可以用搜索功能把项目里所有的 __pycache__ 文件夹全删了。

  3. 删除测试脚本 :把我们之前写的 test_user_crud.py、test_book_crud.py、test_borrow_crud.py 删掉。或者把它们移到一个 tests/ 文件夹里。

  4. 删除本地的 sqlite 文件(如果有) :比如 library.db。

  5. 删除 IDE 生成的文件 :比如 .idea/(PyCharm)、.vscode/。

在你的根目录下新建一个 README.md 文件,简单写一下这个项目是什么、怎么运行。

复制代码
# 图书管理系统后端 API (FastAPI)

## 技术栈
- Python 3.10+, FastAPI, SQLAlchemy (异步), MySQL, JWT

## 如何运行
1. 安装依赖:`pip install -r requirements.txt`
2. 修改 `db/database.py` 中的 `DATABASE_URL` 为你的 MySQL 连接信息。
3. 执行数据库迁移:`alembic upgrade head`
4. 启动服务:`uvicorn main:app --reload`
5. 访问接口文档:`http://127.0.0.1:8000/docs`

5、3.压缩项目文件

6、容器化与部署Docker

四、补充

1、

app = FastAPI() :这行代码创建了一个 ASGI(异步服务器网关接口)应用实例。它底层基于 Starlette 框架,负责处理网络请求和路由。

2、

@app.get("/") :这是 Python 的装饰器。它告诉 FastAPI:"当有人向根路径 / 发送 GET 请求时,请调用下面绑定的函数"。它在底层会在 Starlette 的路由表中注册一条记录。

3、

def read_root() :这就是路径操作函数(Path Operation Function)。返回值 {"message": "Hello"} 会被 FastAPI 自动转换成 JSON 格式返回给客户端。

4、

标准分层架构(适合生产环境)

真实的工程通常会采用分层架构(类似 MVC),将"接收请求"、"业务处理"、"数据库操作"严格分开:

text

复制代码
my_project/
├── .venv/                 # 虚拟环境
├── app/                   # 核心应用包
│   ├── __init__.py
│   ├── main.py            # 应用入口,创建 FastAPI 实例
│   ├── api/               # API 接口层(Controller)
│   │   ├── __init__.py
│   │   └── v1/            # 版本控制,比如 v1 接口
│   │       ├── endpoints/ # 具体的接口路由
│   │       └── router.py  # 汇总路由
│   ├── core/              # 核心配置(安全、JWT、环境变量)
│   ├── schemas/           # Pydantic 模型(数据传输对象 DTO,用来校验请求和响应)
│   ├── models/            # 数据库模型(比如 SQLAlchemy 模型)
│   ├── services/          # 业务逻辑层(CRUD 操作、复杂计算)
│   └── db/                # 数据库连接和初始化
├── tests/                 # 测试代码
└── requirements.txt       # 依赖清单
  1. 一个完整的请求生命周期(用户点击网页的"提交订单"):

    请求首先碰到 1.2.10 中间件 (记录日志、跨域处理)。接着进入 1.2.11 依赖注入 (校验 1.2.9 Token 鉴权 ,判断你是谁)。然后进入 1.2.1 路由/分路由 ,找到对应的处理函数。同时,1.2.2 三类请求参数 和 1.2.6 Pydantic 正在严格校验前端传来的数据。校验通过后,进入 1.2.12 CRUD 业务层。业务层通过 1.2.7 ORM / 1.2.8 SQLAlchemy 操作数据库。如果一切顺利,返回 1.2.4 状态码 ;如果出错,触发 1.2.5 异常处理。

  2. 第一,prefix 的斜杠方向。 prefix 必须以 / 开头、不能以 / 结尾 。子路由路径以 / 开头,FastAPI 会自动拼接;如果你在 include_router(prefix="/api/v1/") 中带了尾部斜杠,FastAPI 会直接拒绝或产生非预期路径。

    第二,函数名冲突。 不同模块中出现同名函数(如两个模块都有 def read_item(...))时,OpenAPI 的 operationId 可能冲突,导致文档生成或客户端代码生成出现问题。最直接的解法是在路由装饰器中显式指定 name,或在 include_router 时通过 prefix/tags 从语义上区分。

    第三,路径顺序。 如果同一个 router 中同时存在 @router.get("/{item_id}") 和 @router.get("/me"),静态路径 /me 必须定义在动态路径 /{item_id} 之前 。否则 /me 会被 {item_id} 捕获,导致 422 或错误行为。这是路由匹配的顺序敏感问题,与 APIRouter 本身无关,但在分路由后更容易被忽视。

    第四,模块级依赖与方法级依赖的层级关系。 router 上的 dependencies 会作用于该 router 下所有 路径操作,包括子路由通过 include_router 挂入的路径(取决于挂载顺序和 FastAPI 版本的行为)。如果你需要"模块内大部分接口要鉴权、个别公开",更稳妥的方式是把公开接口拆到独立的无依赖 router 中,而不是依赖 include_router 的覆盖语义。

5、

功能域 + 版本 + 分层

当业务域变多、API 需要迭代时,更常见的结构是:

text

复制代码
app/
├── main.py
├── routers/
│   ├── v1/
│   │   ├── user/
│   │   │   ├── routes.py        # 只定义路径与参数校验
│   │   │   ├── schemas.py       # Pydantic 模型
│   │   │   └── dependencies.py  # 该域专用依赖
│   │   └── order/
│   │       └── routes.py
│   └── v2/
│       └── ...
├── services/                    # 业务逻辑,与路由解耦
├── models/                      # ORM 模型
└── core/                        # 配置、安全、数据库连接

关键约束:路由层(routers/)只负责接收请求、参数校验、调用 service、返回响应 ;业务逻辑下沉到 services/,数据访问下沉到 models/ 或 repositories/。这样路由模块可以保持极薄,测试时也容易隔离。

6、

第一,路径参数与静态路径的顺序。 如果同一模块中同时有 /items/me 和 /items/{item_id},/me 必须定义在前面 。否则 me 会被 {item_id} 捕获,触发类型转换错误或 422。这与"分路由"中提到的顺序敏感问题是同源的。

第二,查询参数的"可选"语义。 q: str = None 和 q: str | None = None 在旧版本 Python 中行为一致,但在现代类型提示下应优先使用 str | None,让 OpenAPI 文档准确反映参数可空性。同时,不要用可变对象(如 list)作为查询参数的默认值,以免在多次请求间共享状态。

7、

请求的生命周期:从字节到 Python 对象

一个请求进入 FastAPI 应用后,经历一条严格有序的链路:

第一站,ASGI 服务器(Uvicorn)。 原始 TCP 字节流在这里被解析成 ASGI 的 scope(请求元数据字典)、receive(接收消息的异步可调用对象)、send(发送响应的异步可调用对象)。这一层不理解业务,只做协议解析。

第二站,Starlette 路由匹配。 根据 scope 中的 method 和 path,按注册顺序匹配路由。匹配失败返回 404 或 405,匹配成功则把控制权交给对应的 endpoint。

第三站,FastAPI 参数解析与校验。 这是框架最核心的一步。FastAPI 从 scope 和请求体中提取:

  • 路径参数:从 URL 模板匹配结果中取

  • 查询参数 :从 scope["query_string"] 解析

  • 请求头 :从 scope["headers"] 读取

  • Cookie:从请求头中提取

  • 请求体 :通过 receive 异步读取字节流,用 Pydantic 模型校验

所有参数被组装成 Python 对象后,依赖注入系统按依赖图依次执行,最后才调用你的路由函数。

第四站,路由函数。 此时你拿到的已经是类型正确的对象------item_id 是 int、item 是 ItemCreate 实例、current_user 是 User 模型。你不需要碰原始字节。

第五站,响应构造。 路由函数的返回值经过 response_model 序列化(Pydantic 再次出场),被包装成 JSONResponse,通过 send 可调用对象写回 ASGI 层,最终由 Uvicorn 编码成 HTTP 字节流。

这条链路的关键含义 :routers/ 层的代码之所以能保持极薄,是因为所有协议解析、校验、序列化都被框架吸收在前置和后置阶段。你只需要关心"拿到什么对象、返回什么对象"。

请求与响应在各层的流转

这是把"请求与响应"从 HTTP 概念落到项目结构的关键。结合前面的分层:

路由层(routers/)是请求与响应的唯一接触面。 它接收框架解析好的参数、调用 service、返回 Pydantic 模型或异常。它不应该 把 Request 对象向下传给 service------service 层不应依赖 HTTP 协议。

Service 层接收的是领域对象,不接触 HTTP。 一个设计良好的 service 方法签名是 async def create_user(data: UserCreate) -> User,它不知道也不关心这个 UserCreate 是从 HTTP body 还是消息队列来的。这样 service 可以在定时任务、CLI、gRPC 中复用。把 Request 传给 service 是分层泄漏的典型信号------它意味着 service 开始依赖 HTTP 细节。

异常是请求到响应的转换点。 service 层抛 ItemNotFoundError(领域异常),全局异常处理器把它映射为 404 响应。这条链路上,service 不知道 404 的存在,路由层不写 if ... raise HTTPException,状态码的决策与设置被清晰分离。

响应模型(schemas/ 下的 *Read)是响应的契约。 路由函数返回 ORM 对象或领域对象,response_model 负责把它序列化成 API 契约声明的形状。响应模型的字段就是客户端能看到的全部------没有出现在模型里的字段(如密码哈希)自动被过滤,这是防止数据泄露的结构性保障,而不是靠人工"记得不要返回"。

8、

CRUD 在三层中的分工

CRUD 是理解分层的最佳切入点,因为它的每个操作都天然跨越三层:

text

复制代码
routers/items/routes.py    ← 接收 HTTP 参数,调用 service,返回响应
        ↓
services/item_service.py   ← 业务规则、事务边界、领域异常
        ↓
models/item.py             ← SQLAlchemy ORM 映射
        ↓
db/session.py              ← AsyncSession 生命周期

Service 层是 CRUD 的真正实现者,每个操作对应一个方法,签名只接收领域对象,不碰 HTTP

为什么 exclude_unset=True 是 PATCH 的核心 :它区分"客户端没传这个字段"(保留原值)和"客户端显式传了 null"(置空)。这是 PATCH 与 PUT 语义差异的落地点------PUT 用完整模型覆盖,PATCH 用 exclude_unset 修补。

Repository 层是可选的 。简单项目里 service 直接操作 AsyncSession 就够;当查询逻辑复杂、需要多处复用时,再引入 repositories/item_repo.py,把 select 语句从 service 中抽离。不要为了"分层好看"过早引入 repository------多一层就多一层需要维护的抽象。


CRUD 不是业务逻辑的全部

这是工程上最重要的认知。CRUD 是数据操作,不是业务规则。 一个接口可以完全是 CRUD,但真实的业务往往超出它:

  • 创建订单时,需要扣减库存、校验优惠券、计算税费 ------这是 POST /orders 背后的业务事务,不是简单的 INSERT

  • 删除用户时,需要级联处理其订单、触发通知、软删除保留审计 ------不是一条 DELETE

  • "取消订单"不是 DELETE,而是状态机转换(PATCH /orders/{id}/status),因为它有业务约束(已发货的订单不能取消)

判断标准 :如果某个操作能用一句 INSERT/SELECT/UPDATE/DELETE 描述,它是 CRUD;如果它需要多个步骤、跨表协调、或依赖当前状态判断,它是领域操作 ,应当建模为独立的 service 方法甚至独立的 endpoint(如 /orders/{id}/cancel),而不是硬塞进 CRUD 的四个动词里。

把复杂业务硬套 CRUD 是反模式 ------它会导致 PUT /orders/{id} 承载了"修改地址""取消订单""支付"三种完全不同的语义,路由函数里堆满 if status == ...。更清晰的做法是:CRUD 处理资源的常规生命周期,领域操作单独建模。

9、

中间件应该放在哪一层

结合之前讨论的分层,中间件的落点很清晰:middleware/ 或 core/middleware.py,且必须保持"薄"。

text

复制代码
app/
├── core/
│   └── middleware.py        # 自定义中间件定义
├── main.py                  # 注册中间件(add_middleware / @app.middleware)
└── ...

中间件不访问数据库,不调用 service。 这是硬约束。原因有两个:

第一,执行时机 。中间件在路由匹配之前执行,此时 FastAPI 的依赖注入系统还没启动,Depends(get_db) 不可用。如果你在中间件里手动创建数据库会话,就绕过了依赖注入的生命周期管理,容易造成连接泄漏。

第二,职责边界。中间件是"协议层"的横切逻辑,它应该只关心 HTTP 层面的元数据(headers、状态码、耗时、客户端 IP)。一旦中间件开始查数据库、判断业务规则,它就和依赖注入的职责重叠了,而且无法享受依赖注入的可测试性。

认证不应该写成中间件。 一个常见的新手做法是在中间件里解析 token、查用户、挂到 request.state 上。这会导致:所有路由都被迫认证(无法区分公开和私有接口)、中间件依赖数据库会话、测试时必须模拟整个中间件链。正确做法是 Depends(get_current_user),按路由声明。

中间件适合的"横切关注点"清单:

  • 请求 ID 注入(X-Request-ID,贯穿日志)

  • 访问日志(方法、路径、状态码、耗时)

  • 响应头注入(安全头、耗时头)

  • CORS、GZip、HTTPS 重定向

  • 全局限流(按 IP)

  • 全局异常兜底(捕获未处理异常,返回统一格式)

10、

schema 应该放在哪

结合之前的分层,落点非常明确:

text

复制代码
app/
├── schemas/                  # 或按领域拆分
│   ├── __init__.py
│   ├── user.py               # UserCreate / UserUpdate / UserRead
│   └── item.py
├── models/                   # SQLAlchemy ORM 模型,与 schemas 物理分离
├── services/                 # 接收 schema 对象,操作 ORM 模型
└── routers/                  # 用 schema 声明请求体和 response_model

领域规模变大后,按领域组织比按类型组织更可维护:

text

复制代码
app/
├── features/
│   ├── users/
│   │   ├── router.py
│   │   ├── schemas.py        # 该领域的 Pydantic 契约
│   │   ├── models.py         # 该领域的 ORM 模型
│   │   └── service.py
│   └── orders/
│       ├── router.py
│       ├── schemas.py
│       └── ...

无论哪种组织方式,schemas/ 与 models/ 物理分离是硬约束 。schema 属于接口契约层,不依赖数据库;ORM 模型属于持久化层,不依赖 HTTP。两者通过 service 层和 from_attributes=True 连接,而不是通过继承。

11、

|-----------------------------|-------------------------|----------------------------------------------------|
| session.execute(stmt) | 发送具体的 SQL 去查、改、删。 | 只要你要执行具体的查询条件、更新条件时。 |
| session.get(Book, 1) | 快捷查询单条(按主键)。 | 已知主键 ID,想查单条数据时(性能更好,有缓存)。 |
| session.add(book) | 仅仅把对象放到内存中,不发送 SQL。 | 新增数据时。只有等你执行 await session.commit() 时,才会真正发给数据库。 |

12、

  1. 全异步高并发架构:

    • 使用了 FastAPI + SQLAlchemy 2.0 + aiomysql 的全异步技术栈,非阻塞 I/O 模型相比传统同步框架(如 Flask/Django)在并发处理上性能极大提升。
  2. 完美的防超卖设计:

    • 在借阅核心业务中,利用数据库的 悲观锁 (with_for_update) 加上 事务控制 (begin/commit/rollback),彻底杜绝了高并发下多个用户同时借走最后一本书的"超卖"问题。
  3. 严密的 RBAC 权限控制:

    • 基于 JWT Token 和 FastAPI 的依赖注入 (Depends),实现了游客、读者、管理员的三级权限隔离。未登录返回 401,越权操作返回 403,权限校验与业务代码完全解耦。
  4. 工业级的数据校验与安全:

    • 入参使用 Pydantic v2 的 Field 进行严格约束(如邮箱格式、价格 > 0、库存 >= 0)。

    • 密码使用 bcrypt 加盐哈希,出参使用 response_model 自动过滤 hashed_password,防止敏感信息泄露。

  5. 工程化的数据库版本管理:

    • 抛弃了简单的 create_all,引入了 Alembic 进行数据库迁移,能够优雅地应对模型字段的迭代升级。
  6. 敏感信息脱敏 :

    在 db/database.py 里,把 echo=True 关掉,或者把数据库连接密码放到 .env 文件里(使用 pydantic-settings 读取),展示你的安全意识。

  7. 添加超时自动取消功能(定时任务) :

    比如"借书超过 30 天未还,自动产生逾期记录"。可以通过 FastAPI 的 BackgroundTasks 实现,这能极大地丰富你的业务逻辑。

  8. 统一的全局异常处理 :

    目前我们是在每个路由里写 try...except ValueError。你可以通过 @app.exception_handler 定义一个全局异常处理器,拦截所有 ValueError 并自动转换为 400,代码会更清爽。

  9. 前端对接 :

    如果你需要写前端(Vue/React),现在的接口(/docs)已经非常标准,直接根据 Swagger 的定义对接即可。

nn、

明确需求文档(需求分析、数据库设计、初始化仓库与环境、搭建工程骨架)->

数据核心模型(配置管理、定义数据库模型'models'、定义校验模型'schemas'、数据库连接池)->

步骤一:基建先行 ------ 配置数据库连接 (app/db/database.py)

目标:让 Python 应用能够连上数据库,并准备好操作数据库的工具。

  • 1.1 确定数据库 URL :本项目在开发阶段使用 SQLite(例如 sqlite:///./library.db)。

  • 1.2 创建引擎 (Engine):负责管理数据库连接池。

  • 1.3 创建会话工厂 (SessionLocal):用来生成每次请求操作数据库的"手柄"。

  • 1.4 创建模型基类 (Base):让所有 SQLAlchemy 模型继承它。

  • 1.5 编写依赖函数 (get_db) :准备后续用于依赖注入,负责在请求结束时自动关闭数据库会话。

步骤二:物理建表 ------ 编写 SQLAlchemy 模型 (app/models/)

目标 :将需求文档中的"数据字典"翻译成 Python 类,真正在数据库里建表。(注意:一表一文件,便于维护)

  • 2.1 编写 User 模型 (user.py) :继承 Base,定义 __tablename__、主键 ID、用户名、邮箱、加密密码 、is_admin。

  • 2.2 编写 Book 模型 (book.py) :定义 __tablename__、主键 ID、书名、作者、ISBN、库存量。

  • 2.3 编写 Borrow 模型 (borrow.py):定义外键关系。

    • user_id -> ForeignKey("users.id")

    • book_id -> ForeignKey("books.id")

    • 定义借阅时间和归还时间。

  • 2.4 建立表间关系 (Relationship) (进阶):配置 relationship,让你以后能方便地通过 user.borrow_records 直接拿到该用户的所有借阅记录。

步骤三:边界防守 ------ 编写 Pydantic 数据校验模型 (app/schemas/)

目标 :定义 API 接口的"输入"与"输出"标准,把脏数据挡在业务逻辑之外。(这部分与 Models 高度对应,但职责完全不同)

  • 3.1 基础模型 (BaseSchema) :抽出共用的字段,如 UserBase 包含 username, email。

  • 3.2 请求模型 (In):

    • UserCreate:包含明文 password(供注册用)。

    • BookCreate:包含建表所需的所有字段。

    • LoginRequest:包含 username 和 password。

  • 3.3 响应模型 (Out):

    • UserOut:绝不能包含密码!只包含 ID, username, email, is_admin。

    • BookOut:包含图书所有展示字段。

  • 3.4 配置 ORM 模式 :在所有 Out 模型中设置 model_config = ConfigDict(from_attributes=True),允许从 SQLAlchemy 对象自动转换。

  • 3.5 编写 Token 模型 (token.py) :定义登录成功后返回的 JWT Token 结构(如 access_token, token_type)。

步骤四:初始化与测试 ------ 验证模型是否生效

目标:确认上述设计能否真正在数据库中跑通。

  • 4.1 挂载建表指令 :在 main.py 或数据库初始化脚本中调用 Base.metadata.create_all(bind=engine)。

  • 4.2 运行验证 :启动应用,检查项目根目录下是否生成了 library.db 文件。

  • 4.3 验证数据结构 :用 DBeaver 或 VS Code 插件打开数据库,检查 users、books 表及其外键是否符合预期。

核心业务开发(编写CRUD、构建路由'routers'、安全与鉴权、异常处理与中间件、组装入口)->

一、 架构分层思路(数据怎么流动?)

一句话总结:Router 只管接客,CRUD 只管干活,Schema 只管安检。

  1. Router (接口层) :只负责接收前端传来的参数(Query/Path/Body/Token),调用 crud/ 里的函数,然后返回结果。绝对不要在这里写 SQLAlchemy 的查询语句。

  2. CRUD (业务逻辑层) :封装了所有对数据库的增删改查。比如 get_user_by_username、create_borrow_record。在这里我们会处理复杂的业务判断(比如库存是否充足)。

  3. Models & Schemas:一个对接数据库(存什么),一个对接前端(传什么/回什么)。

  4. Deps (依赖注入):所有接口的安全门神,提前把当前用户和数据库 Session 准备好。


二、 核心难点剖析:借阅业务(重点!)

这是你课设拿高分的核心:并发防超卖与事务控制 。需求文档明确提出了"防止超卖",所以这里绝对不能简单写个 book.stock -= 1 完事。

🔴 借书逻辑流程 (POST /borrows/{book_id})

  1. 前置校验:

    • 获取当前登录用户(Depends(get_current_user))。

    • 校验该用户是否已经借了这本书且没还(防止重复借阅)。

  2. 开启事务与并发锁:

    • 使用 async with db.begin(): 开启事务。

    • 关键动作 :查询图书时,加上 with_for_update() 悲观锁。这会在数据库层面锁住这行数据,防止两个用户同时读到了 stock=1。

  3. 库存校验与扣减:

    • 判断 book.stock > 0,如果不足,抛出 400 库存不足。

    • book.stock -= 1。

  4. 生成记录 :在 borrow_records 表插入一条新记录,此时 return_date 为 None。

  5. 提交事务 :数据库自动 commit()。如果中间任何一步报错,整个事务 rollback()。

🟢 还书逻辑流程 (POST /borrows/{record_id}/return)

  1. 校验记录 :根据 record_id 查记录,判断是否属于当前用户。

  2. 校验状态 :判断 return_date 是否为空。如果不为空,说明已经还过了,报错。

  3. 事务处理:

    • 更新 return_date = datetime.utcnow()。

    • 找到对应的图书,book.stock += 1。

    • commit() 提交事务。


三、 鉴权与权限控制思路 (RBAC)

  1. 密码处理 :在 core/security.py 中,使用 passlib (搭配 bcrypt) 对密码进行哈希,登录时校验哈希,保证数据库绝不存明文。

  2. JWT Token:

    • 登录成功时,用 python-jose 生成 Token,Payload 里塞入 sub: username,并设置过期时间。

    • 后续每次请求,前端把 Token 放在 Header (Authorization: Bearer xxx) 里传过来。

  3. 依赖注入拦截 (routers/deps.py):

    • get_current_user:解析 Token,查数据库,返回 User 对象。如果 Token 无效,返回 401。

    • get_current_admin:依赖 get_current_user,进一步检查 user.is_admin == True。如果不是,返回 403 Forbidden。


四、 开发顺序规划(我们接下来怎么一步步写)

为了不打乱仗,我们严格按照以下顺序推进,每一步写完都能独立测试:

  1. 第一步:安全底座 (core/security.py & routers/deps.py)

    • 写密码加密、JWT 生成与解析代码,以及获取当前用户的依赖。这是所有接口的基础。
  2. 第二步:基础 CRUD (crud/user.py & crud/book.py)

    • 写用户和图书的增删改查。这几张表互不干扰,难度较低,便于热身。
  3. 第三步:核心业务 (crud/borrow.py)

    • 专门写借书、还书的事务与并发锁逻辑(全项目最难的部分)。
  4. 第四步:路由层组装 (routers/)

    • 把所有的接口暴露出去,配置好权限拦截,联调。
  5. 第五步:入口整合 (main.py)

    • 挂载路由,配置 CORS(跨域),测试全局接口。

联调与测试(接口测试、编写逻辑单元、依赖检查)->

容器化与部署(编写Dockerfile、分离运行环境、配置网关与代理、数据库迁移)->

监控与迭代(日志与监控、热修复与迭代、版本升级)

相关推荐
外收内放1 小时前
ENSP学习记录(6)
开发语言·学习·php
叼烟扛炮12 小时前
[Git 实战] 学习笔记:从生成 SSH 密钥到解决 TortoiseGit 推送报错全指南
linux·运维·笔记·git·学习
蒸蒸yyyyzwd13 小时前
学习笔记 八股手撕
笔记·学习
留岁15 小时前
大模型推理部署 · 学习文档
学习
小李不想当小白16 小时前
BKP备份寄存器与RTC实时时钟(STM32标准库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
陈飞EXCEL17 小时前
北京杰飞 IT 旗下妙码生花少儿Python编程上线两大学习系统
开发语言·python·学习
SLAM必须dunk17 小时前
Agent入门学习一
学习
承渊政道19 小时前
Linux网络学习【从NAT与代理服务理解网络转发、探索内网穿透原理、再到认识DNS与ICMP、最后动手部署并测试内网穿透】
linux·网络·学习·内网穿透·dns·nat
栉甜20 小时前
Vue2学习内容(上)
前端·javascript·vue.js·学习