目录
0、1.Window搭建python虚拟环境(pycharm专业版)
[1、2.8)SQLAlchemy 是什么](#1、2.8)SQLAlchemy 是什么)
[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.4*完整的 根据是否完成任务分页查询所有 带鉴权的 接口](#2、1.4*完整的 根据是否完成任务分页查询所有 带鉴权的 接口)
完整项目地址:https://gitee.com/leecyldy/aistudy.git
[2、2.在models中 创建实体类对象并使用Alembic迁移建表](#2、2.在models中 创建实体类对象并使用Alembic迁移建表)
2、3.编写Pydantic数据校验模型(schemas)请求体
3、2.拦截请求、判断用户是否登录routers/deps.py
第一步:注册普通用户 (POST /api/users/register)
[第二步:登录获取 Token (POST /api/users/login)](#第二步:登录获取 Token (POST /api/users/login))
第六步:借书 (POST /api/borrows/{book_id})
第八步:还书 (POST /api/borrows/{record_id}/return)
一、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
├── crud/ # 业务逻辑与数据库操作
│ ├── user.py
│ └── book.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 也会直接丢掉
-
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等属性,极大地简化了代码。
- 从数据库查出来的是
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)
-
gt=0(Greater Than) :Pydantic 的内置校验器。如果前端乱传-5的价格,直接返回422错误,保护了数据库。 -
ge=0(Greater Than or Equal):同样,保证库存不出现负数。 -
BookUpdate全部设为Optional:-
需求里写的是
PUT修改图书。如果管理员只想修改价格,不想传书名,那么传统的BookBase会报错说缺少 title 字段。 -
把所有字段设为
Optional[...] = None,允许前端只传需要修改的字段(这在现代 API 设计中,相当于做成了 PATCH 的效果,非常实用)。
-
-
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)
-
为什么没有
BorrowCreate?- 你的需求文档中,借书接口是
POST /{book_id}。图书 ID 通过Path参数传,用户 ID 通过 JWT Token 解析获得。所以借书根本不需要前端传 Body 过来,自然就不需要创建模型。
- 你的需求文档中,借书接口是
-
关系嵌套:
-
这里的
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
-
为什么用
bcrypt而不是 MD5?- MD5 已经被证明不安全(容易被彩虹表撞库破解)。
bcrypt自带"加盐"功能,且计算速度故意设计得很慢,黑客无法通过暴力破解。而且,每次对同一个密码哈希出来的结果都不一样,但verify方法总能正确匹配。
- MD5 已经被证明不安全(容易被彩虹表撞库破解)。
-
timezone.utc的运用 :JWT 的exp字段必须是 UTC 时间。这里使用了带时区的timezone.utc,完美避免了服务器所在时区带来的时间偏差问题。 -
sub字段 :JWT 标准规定,sub(Subject) 用于存储用户的唯一标识。后面我们在依赖注入里,只要解析出这个sub,就能知道当前是哪个用户登录了。 -
为什么
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
-
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]或空列表报错。
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
-
await db.commit()与await db.refresh():-
在异步操作中,
commit是必须await的,它才会真正把数据发给 MySQL。 -
refresh的作用是:因为我们在 Python 内存中add了这个对象,它的id和created_at还是空的。执行refresh后,SQLAlchemy 会立刻去数据库查一遍,把自增的 ID 和生成的时间填回这个对象里。
-
-
get_password_hash的复用 :CRUD 层不处理业务规则,只负责"存"。具体的加密算法细节全部藏在core/security.py中,这就是分层的魅力。 -
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()
-
or_与ilike:or_实现了"搜索书名 或 作者"的逻辑;ilike是大小写不敏感的模糊匹配(MySQL 中like默认不区分大小写,但ilike在不同数据库中兼容性更好)。 -
Tuple[Sequence[Book], int]:对于带有分页的列表接口,仅仅返回一个List[Book]是不够的。前端需要知道"总共有多少本书"才能画出分页控件。所以这里同时返回了当前页的数据和total总数。 -
exclude_unset=True:这是BookUpdate的绝佳拍档。如果前端只传了{"price": 99.9},那么update_data里就只有price。通过setattr循环,我们只会修改价格,而不会把title变成None。 -
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()
-
with_for_update()(悲观锁) :这是防止"超卖"的终极武器。假设库存只有 1 本,A 用户和 B 用户在同一毫秒点击借书。数据库收到两个请求,如果是 A 先拿到锁,B 就会被阻塞挂起,直到 A 扣完库存并提交事务,B 才能继续,此时 B 读到的stock已经是0,直接抛出"库存不足",完美避开超卖。 -
async with db.begin()::这是 SQLAlchemy 2.0 异步提供的事务上下文。如果在begin块内发生任何异常(比如raise ValueError),数据库会自动回滚(Rollback),保证book.stock绝对不会被错误扣减。 -
await db.flush():在事务还没commit之前,把 SQL 发给数据库,这样我们可以拿到db_record.id。因为外面有begin()包裹,所以真正的提交是在块结束时自动完成的。 -
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。
- 注意:为了让 Swagger UI 的 Authorize 按钮生效,这里我们需要使用 FastAPI 的
-
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 个核心工作
-
依赖注入 (Depends):
-
需要用到我们写好的
get_db。 -
需要用到
get_current_user和get_current_admin来控制权限(拦截 401 和 403)。
-
-
异常捕获与转换:
- 我们的
crud层抛出的是纯 Python 的ValueError。在 Router 层,必须用try...except ValueError as e:捕获它,并转换成 FastAPI 的HTTPException(status_code=400, detail=str(e)),这样前端才能收到友好的报错提示。
- 我们的
-
响应模型 (Response Model):
- 利用 FastAPI 的
response_model=UserResponse参数,直接返回 ORM 对象,FastAPI 会自动调用 Pydantic 帮你过滤敏感字段(比如密码)并转成 JSON。
- 利用 FastAPI 的
-
分页响应的结构:
-
图书列表需要返回
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
-
OAuth2PasswordRequestForm:-
你可能会疑惑,为什么登录接口不用 JSON(
UserLogin),而是用表单(form_data)? -
这是 FastAPI 的官方设计:只要你使用了
OAuth2PasswordRequestForm,Swagger UI (/docs) 右上角就会自动出现一个 Authorize 按钮。你填入用户名密码登录后,后续所有的请求都会自动带上 Token,测试极其方便。
-
-
response_model=UserResponse:- 这是数据防泄漏的终极手段。就算你的
new_user对象里有hashed_password,只要声明了response_model,FastAPI 就会像漏斗一样,只漏出UserResponse里定义的字段。
- 这是数据防泄漏的终极手段。就算你的
-
异常抛出:
-
我们统一使用了
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": "删除成功"}
-
Query(...)参数校验:- 在
read_books中,我们使用了skip: int = Query(0, ge=0)。这里的ge=0意味着如果前端传了负数(比如?skip=-1),FastAPI 会直接拦截并返回 422 错误。这比你手写if skip < 0优雅得多。
- 在
-
get_current_admin的神奇之处:-
在
create_book、update_book、delete_book中,注入admin: Annotated[dict, Depends(get_current_admin)]。 -
这意味着,如果未登录,请求根本进不来,直接 401;如果登录了但只是读者,直接 403。拦截逻辑完全封装在底层,接口代码非常干净。
-
-
错误捕获与转换:
-
还记得我们在
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)
)
-
路径顺序的讲究:
-
注意看,我把
GET /my放在了POST /{book_id}前面。 -
虽然它们一个是 GET 一个是 POST,在 FastAPI 里不会直接冲突,但这是好习惯。如果未来前端发个
GET /my,FastAPI 会优先匹配到无参数的固定路径,避免把它误认为book_id="my"导致类型报错。
-
-
极简的权限控制:
-
在三个接口中,我们都注入了
current_user: Annotated[User, Depends(get_current_user)]。 -
只要用户带着有效的 Token,
current_user就会自动变成当前登录用户的实例。我们直接把current_user.id传给 CRUD 层,彻底避免了前端伪造user_id越权操作的风险。
-
-
统一的异常捕获:
-
你的 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)
-
在 Swagger UI 页面,展开
POST /api/users/register。 -
点击右侧的 Try it out 按钮。
-
在 Request body 框里输入以下 JSON:
json
{ "username": "student001", "email": "student001@example.com", "password": "password123" } -
点击 Execute。
-
预期结果 :Server response 的 Code 应该是
200,返回的 JSON 里包含id、username、email、is_admin: false,并且绝对没有password字段。
第二步:登录获取 Token (POST /api/users/login)
-
滚动到页面最上方,找到右上角的 Authorize 按钮(带个小锁图标),点击它。
-
会弹出一个登录框,输入:
-
username :
student001 -
password :
password123(注意:这里是表单输入,不需要写 JSON 的引号和花括号)
-
-
点击 Authorize ,如果成功,按钮上的小锁会变成"上锁"状态,点击 Close 。
(此时你的浏览器已经记住了 Token,后续的所有请求都会自动带上
Authorization: Bearer xxx)

第三步:测试权限拦截 (报错 403)
-
滚动到图书模块,展开
POST /api/books/新增图书接口。 -
点击 Try it out。
-
输入测试图书 JSON:
json
{ "title": "三体", "author": "刘慈欣", "isbn": "9787536692930", "price": 23.0, "stock": 5 } -
点击 Execute。
-
预期结果 :Server response 的 Code 应该是
403 Forbidden,并且 detail 显示"权限不足,仅管理员可操作"。(这证明你的 RBAC 权限拦截非常完美!)

第四步:进数据库"提拔"自己为管理员
-
打开你的数据库管理工具(Navicat / DBeaver / MySQL 命令行)。
-
找到
library_db库下的users表。 -
找到
student001这行数据,把is_admin字段的值从0改成1,保存。 -
回到 Swagger UI,重新点击右上角 Authorize ,再次输入用户名密码登录。
(为什么要重新登录?虽然你的代码每次都会查库,但养成修改权限后重新登录的习惯能防止 Token 缓存问题)

第五步:再次新增图书 (成功)
-
重复第三步 的操作,再次在
POST /api/books/里输入"三体"的数据,点击 Execute。 -
预期结果 :Code 为
200,返回创建成功的图书对象,包含id: 1。(去数据库里看一眼,
books表里是不是多了一本书)


第六步:借书 (POST /api/borrows/{book_id})
-
展开
POST /api/borrows/{book_id}借阅接口。 -
点击 Try it out ,在
book_id输入框填入刚才生成的图书 ID:1。 -
点击 Execute。
-
预期结果 :Code 为
200,返回一条借阅记录,里面有id(比如 1),borrow_date有值,return_date为null。(去数据库看一眼,
books表里"三体"的stock应该变成了 4)
在crud/borrow.py中
你的
borrow_book接口定义了返回模型response_model=BorrowRecordResponse。
BorrowRecordResponse里面嵌套了book: BookResponse和user: UserResponse。当 FastAPI 拿到你返回的
record对象,准备组装成 JSON 给前端时,它需要去读取record.book.title等信息。但是!你的
crud.borrow_book函数在commit后,只返回了一个单纯的借阅记录,没有提前把book和user查出来。于是 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)
-
展开
GET /api/borrows/my获取我的借阅记录接口。 -
点击 Try it out ,直接点击 Execute(不需要传任何参数)。
-
预期结果 :返回一个 JSON 数组。里面的
book字段应该嵌套了"三体"的详细信息(书名、作者等),user字段应该嵌套了student001的信息。(这证明我们的
selectinload预加载和 Pydantic 嵌套模型完美生效!)

第八步:还书 (POST /api/borrows/{record_id}/return)
-
展开还书接口
POST /api/borrows/{record_id}/return。 -
点击 Try it out ,在
record_id填刚才查出来的借阅记录 ID(比如1)。 -
点击 Execute。
-
预期结果 :Code 为
200,返回的 JSON 里return_date变成了当前时间。(去数据库看一眼,
books表里"三体"的stock变回了 5)

5、打包项目
针对 Python 后端项目,"打包"通常包含三个层面的工作:
5、1.生成依赖清单 (requirements.txt)
在项目根目录 (并确保你激活了 fastapi_venv 虚拟环境),运行:
pip freeze > requirements.txt
5、2.清理项目垃圾(删掉临时文件、缓存、测试脚本等)
检查并删除 以下文件/文件夹(如果是完整的项目,建议写一个 .gitignore 文件,但课设直接手动删即可):
-
删除虚拟环境 :
my_env/、fastapi_venv/、venv/等(千万别打包这个,别人没法用)。 -
删除 Python 缓存 :所有名为
__pycache__的文件夹。你可以用搜索功能把项目里所有的__pycache__文件夹全删了。 -
删除测试脚本 :把我们之前写的
test_user_crud.py、test_book_crud.py、test_borrow_crud.py删掉。或者把它们移到一个tests/文件夹里。 -
删除本地的 sqlite 文件(如果有) :比如
library.db。 -
删除 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.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 异常处理。
-
第一,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、
-
全异步高并发架构:
- 使用了
FastAPI + SQLAlchemy 2.0 + aiomysql的全异步技术栈,非阻塞 I/O 模型相比传统同步框架(如 Flask/Django)在并发处理上性能极大提升。
- 使用了
-
完美的防超卖设计:
- 在借阅核心业务中,利用数据库的 悲观锁 (
with_for_update) 加上 事务控制 (begin/commit/rollback),彻底杜绝了高并发下多个用户同时借走最后一本书的"超卖"问题。
- 在借阅核心业务中,利用数据库的 悲观锁 (
-
严密的 RBAC 权限控制:
- 基于 JWT Token 和 FastAPI 的依赖注入 (
Depends),实现了游客、读者、管理员的三级权限隔离。未登录返回401,越权操作返回403,权限校验与业务代码完全解耦。
- 基于 JWT Token 和 FastAPI 的依赖注入 (
-
工业级的数据校验与安全:
-
入参使用 Pydantic v2 的
Field进行严格约束(如邮箱格式、价格 > 0、库存 >= 0)。 -
密码使用
bcrypt加盐哈希,出参使用response_model自动过滤hashed_password,防止敏感信息泄露。
-
-
工程化的数据库版本管理:
- 抛弃了简单的
create_all,引入了 Alembic 进行数据库迁移,能够优雅地应对模型字段的迭代升级。
- 抛弃了简单的
-
敏感信息脱敏 :
在
db/database.py里,把echo=True关掉,或者把数据库连接密码放到.env文件里(使用pydantic-settings读取),展示你的安全意识。 -
添加超时自动取消功能(定时任务) :
比如"借书超过 30 天未还,自动产生逾期记录"。可以通过 FastAPI 的
BackgroundTasks实现,这能极大地丰富你的业务逻辑。 -
统一的全局异常处理 :
目前我们是在每个路由里写
try...except ValueError。你可以通过@app.exception_handler定义一个全局异常处理器,拦截所有ValueError并自动转换为400,代码会更清爽。 -
前端对接 :
如果你需要写前端(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 只管安检。
Router (接口层) :只负责接收前端传来的参数(Query/Path/Body/Token),调用
crud/里的函数,然后返回结果。绝对不要在这里写 SQLAlchemy 的查询语句。CRUD (业务逻辑层) :封装了所有对数据库的增删改查。比如
get_user_by_username、create_borrow_record。在这里我们会处理复杂的业务判断(比如库存是否充足)。Models & Schemas:一个对接数据库(存什么),一个对接前端(传什么/回什么)。
Deps (依赖注入):所有接口的安全门神,提前把当前用户和数据库 Session 准备好。
二、 核心难点剖析:借阅业务(重点!)
这是你课设拿高分的核心:并发防超卖与事务控制 。需求文档明确提出了"防止超卖",所以这里绝对不能简单写个
book.stock -= 1完事。🔴 借书逻辑流程 (
POST /borrows/{book_id})
前置校验:
获取当前登录用户(
Depends(get_current_user))。校验该用户是否已经借了这本书且没还(防止重复借阅)。
开启事务与并发锁:
使用
async with db.begin():开启事务。关键动作 :查询图书时,加上
with_for_update()悲观锁。这会在数据库层面锁住这行数据,防止两个用户同时读到了stock=1。库存校验与扣减:
判断
book.stock > 0,如果不足,抛出400 库存不足。
book.stock -= 1。生成记录 :在
borrow_records表插入一条新记录,此时return_date为None。提交事务 :数据库自动
commit()。如果中间任何一步报错,整个事务rollback()。🟢 还书逻辑流程 (
POST /borrows/{record_id}/return)
校验记录 :根据
record_id查记录,判断是否属于当前用户。校验状态 :判断
return_date是否为空。如果不为空,说明已经还过了,报错。事务处理:
更新
return_date = datetime.utcnow()。找到对应的图书,
book.stock += 1。
commit()提交事务。
三、 鉴权与权限控制思路 (RBAC)
密码处理 :在
core/security.py中,使用passlib(搭配bcrypt) 对密码进行哈希,登录时校验哈希,保证数据库绝不存明文。JWT Token:
登录成功时,用
python-jose生成 Token,Payload 里塞入sub: username,并设置过期时间。后续每次请求,前端把 Token 放在
Header (Authorization: Bearer xxx)里传过来。依赖注入拦截 (
routers/deps.py):
get_current_user:解析 Token,查数据库,返回 User 对象。如果 Token 无效,返回401。
get_current_admin:依赖get_current_user,进一步检查user.is_admin == True。如果不是,返回403 Forbidden。
四、 开发顺序规划(我们接下来怎么一步步写)
为了不打乱仗,我们严格按照以下顺序推进,每一步写完都能独立测试:
第一步:安全底座 (
core/security.py&routers/deps.py)
- 写密码加密、JWT 生成与解析代码,以及获取当前用户的依赖。这是所有接口的基础。
第二步:基础 CRUD (
crud/user.py&crud/book.py)
- 写用户和图书的增删改查。这几张表互不干扰,难度较低,便于热身。
第三步:核心业务 (
crud/borrow.py)
- 专门写借书、还书的事务与并发锁逻辑(全项目最难的部分)。
第四步:路由层组装 (
routers/)
- 把所有的接口暴露出去,配置好权限拦截,联调。
第五步:入口整合 (
main.py)
- 挂载路由,配置 CORS(跨域),测试全局接口。
联调与测试(接口测试、编写逻辑单元、依赖检查)->
容器化与部署(编写Dockerfile、分离运行环境、配置网关与代理、数据库迁移)->
监控与迭代(日志与监控、热修复与迭代、版本升级)






