FastAPI 用户 CRUD:单条查询、部分更新与删除
用户创建和列表查询接入 PostgreSQL 后,还需要补上三个操作:按 ID 查询、修改用户字段、删除用户
本文继续使用 Router、Service、Repository 分层,把不同的业务结果转换成明确的 HTTP 响应,同时处理部分更新中容易混淆的"未提供字段"和"显式 null"
一、先确定接口与职责
完成后的用户接口如下:
| 方法 | 路径 | 用途 | 成功状态 |
|---|---|---|---|
| POST | /users |
创建用户 | 201 |
| GET | /users |
查询用户列表 | 200 |
| GET | /users/{user_id} |
查询单个用户 | 200 |
| PATCH | /users/{user_id} |
修改部分字段 | 200 |
| DELETE | /users/{user_id} |
删除用户 | 204 |
Router 负责参数、状态码和异常映射,Service 判断业务规则,Repository 执行数据库操作。本文新增代码都放在现有的 app/routers/users.py、app/services/users.py 和 app/repositories/users.py 中
二、Repository 按主键查询
在 UserRepository 类中增加:
python
def get_by_id(self, user_id: int) -> User | None:
return self._session.get(User, user_id)
Session.get() 接收 ORM 模型类和主键值。模型已经通过 primary_key=True 声明了主键,因此不需要另外写 where(User.id == user_id)
User | None 是联合类型:找到记录返回 User,找不到返回 None。Repository 只报告结果,不在这里抛 HTTP 404
三、用异常类型表达业务失败
查询、创建和更新可能因为不同原因失败。在 Service 模块的导入区域之后定义:
python
class UserNotFoundError(Exception):
pass
class UsernameAlreadyExistsError(Exception):
pass
class InvalidUserUpdateError(Exception):
pass
继承 Exception 就能定义自己的异常类别,pass 表示暂时不增加自定义行为
例如:
python
raise UserNotFoundError("User not found")
异常类型供程序分类,字符串供人阅读。Router 可以稳定地按类型捕获,不必比较错误消息,也不会把其他代码抛出的 ValueError 误认为用户名冲突
原来的创建函数也同步使用新异常:
python
if repository.exists_by_username(user_data.username):
raise UsernameAlreadyExistsError("Username already exists")
对应 POST 路由改为捕获 UsernameAlreadyExistsError,保留原来的 409 响应
四、Service 查询并转换响应
在 Service 中增加:
python
def get_user_by_id(user_id: int, repository: UserRepository) -> UserResponse:
user = repository.get_by_id(user_id)
if user is None:
raise UserNotFoundError("User not found")
return UserResponse(
id=user.id,
username=user.username,
display_name=user.display_name,
)
先排除 None,后面才能安全读取属性。这既是业务判断,也是类型缩小:继续执行到返回语句时,user 已确定是 ORM User
这里返回的是 UserResponse,供接口序列化使用。它与数据库中的 ORM User 是不同类型,后面执行更新和删除时仍然需要 ORM 对象
五、路径参数与 404、422
Router 补充导入:
python
from app.services.users import UserNotFoundError
from app.services.users import get_user_by_id as get_user_by_id_service
沿用已有的 router = APIRouter(prefix="/users", tags=["users"]),增加:
python
@router.get("/{user_id}", response_model=UserResponse)
def get_user_by_id(
user_id: int,
session: Annotated[Session, Depends(get_session)],
) -> UserResponse:
repository = UserRepository(session)
try:
return get_user_by_id_service(user_id, repository)
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
/{user_id} 中的名称与函数参数对应,FastAPI 根据 user_id: int 解析路径中的值
| 输入 | 处理过程 |
|---|---|
| 已存在的整数 ID | 查到用户,返回 200 |
| 不存在的整数 ID | 输入格式合法,查询无结果,返回 404 |
abc |
无法解析为整数,进入路由函数前返回 422 |
当前只声明 int,没有额外要求 ID 大于零,因此负整数也能通过类型解析,随后根据是否存在返回查询结果
raise ... from exc 保留后端异常的因果关系,响应中只返回指定的 detail
六、PATCH 只修改提供的字段
只修改昵称时,请求可以是:
http
PATCH /users/1
Content-Type: application/json
json
{
"display_name": "新昵称"
}
没有提供 username,表示保留原来的用户名
在 app/schemas/users.py 增加:
python
class UserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=20)
display_name: str | None = Field(default=None, min_length=1, max_length=50)
default=None 允许字段不出现,str | None 允许字段值为字符串或 None。两者分别描述默认值与值的类型
下面两个请求并不相同:
json
{}
json
{"username": null}
前者没有提交字段,后者明确提交了 username,只是值为 null
七、exclude_unset 保留输入意图
提取更新字段时使用:
python
raw_changes = update.model_dump(exclude_unset=True)
model_dump() 转换成字典,exclude_unset=True 排除没有明确设置的字段:
python
UserUpdate().model_dump(exclude_unset=True)
# {}
UserUpdate(display_name="新昵称").model_dump(exclude_unset=True)
# {'display_name': '新昵称'}
UserUpdate(username=None).model_dump(exclude_unset=True)
# {'username': None}
unset 不等于 None。显式提交的 null 必须留下来,Service 才能将它识别为无效更新,而不是默默忽略
当前数据库字段不允许空值,本接口约定空更新和显式 null 返回 400;字符串长度不符合 Schema 则由 Pydantic 返回 422
八、字典与动态属性更新
用于保存更新项的变量是:
python
changes: dict[str, str] = {}
dict[str, str] 表示键和值都是字符串的字典,类似 TypeScript 的 Record<string, string>。{} 创建空字典,不是数组
python
changes["display_name"] = "新昵称"
# {'display_name': '新昵称'}
如果需要多个这样的字典,才会使用 list[dict[str, str]] 和列表 []
Repository 的更新方法为:
python
def update(self, user: User, changes: dict[str, str]) -> User:
for field, value in changes.items():
setattr(user, field, value)
self._session.commit()
self._session.refresh(user)
return user
items() 逐项提供键和值,setattr() 按字符串指定属性:
python
setattr(user, "display_name", "新昵称")
# 等价于 user.display_name = "新昵称"
user 来自同一个 Session 的查询,已经受 Session 管理,修改属性后会被跟踪,提交时执行更新,无需重新 add
这里的 changes 应由 Service 检查,只包含允许修改的字段,不能把任意外部字典直接交给 setattr
九、Service 完整处理更新规则
在 Service 的 Schema 导入中加入 UserUpdate,更新函数如下:
python
def update_user(
user_id: int,
update: UserUpdate,
repository: UserRepository,
) -> UserResponse:
existing_user = repository.get_by_id(user_id)
if existing_user is None:
raise UserNotFoundError("User not found")
raw_changes = update.model_dump(exclude_unset=True)
if not raw_changes:
raise InvalidUserUpdateError("At least one field must be provided")
changes: dict[str, str] = {}
for field, value in raw_changes.items():
if value is None:
raise InvalidUserUpdateError("Update fields cannot be null")
changes[field] = value
new_username = changes.get("username")
if (
new_username is not None
and new_username != existing_user.username
and repository.exists_by_username(new_username)
):
raise UsernameAlreadyExistsError("Username already exists")
updated_user = repository.update(existing_user, changes)
return UserResponse(
id=updated_user.id,
username=updated_user.username,
display_name=updated_user.display_name,
)
这段代码先找用户,再检查更新内容,因此不存在的 ID 即使提交空对象,也会先得到用户不存在的业务错误
用户名检查需要排除自己的原用户名,否则查询占用情况时会把自己判为冲突
changes.get("username") 在键不存在时返回 None。配合 and 的短路判断,只修改昵称不会触发用户名占用查询
Schema 负责字符串类型和长度,Service 负责空更新、null 和当前数据库状态下的用户名冲突
十、PATCH 路由映射不同异常
Router 的导入中加入 UserUpdate、三个业务异常,以及更新函数别名:
python
from app.schemas.users import UserCreate, UserResponse, UserUpdate
from app.services.users import (
InvalidUserUpdateError,
UsernameAlreadyExistsError,
UserNotFoundError,
)
from app.services.users import update_user as update_user_service
增加接口:
python
@router.patch("/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
update: UserUpdate,
session: Annotated[Session, Depends(get_session)],
) -> UserResponse:
repository = UserRepository(session)
try:
return update_user_service(user_id, update, repository)
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except UsernameAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except InvalidUserUpdateError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
三个参数来源不同:user_id 来自 URL,update 来自 JSON 请求体,session 来自依赖注入
400 表示本接口不接受这份更新内容,404 表示目标用户不存在,不能把空更新映射成 404
十一、删除需要 ORM 对象
Repository 增加:
python
def delete(self, user: User) -> None:
self._session.delete(user)
self._session.commit()
Session.get 和 Session.delete 的参数不同:
python
session.get(User, user_id) # 模型类和主键值
session.delete(user) # 受 Session 管理的 ORM 对象
delete 标记对象待删除,commit 提交事务。删除后记录已经不存在,不再调用 refresh
Service 先通过 Repository 获取 ORM 对象:
python
def delete_user(user_id: int, repository: UserRepository) -> None:
user = repository.get_by_id(user_id)
if user is None:
raise UserNotFoundError("User not found")
repository.delete(user)
不能在这里调用返回 UserResponse 的 Service 查询函数,再把响应模型交给 Session.delete。响应模型没有数据库映射关系,不能作为 ORM 对象删除
十二、204 表示成功且没有响应体
Router 增加导入:
python
from app.services.users import delete_user as delete_user_service
删除接口:
python
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(
user_id: int,
session: Annotated[Session, Depends(get_session)],
) -> None:
repository = UserRepository(session)
try:
delete_user_service(user_id, repository)
except UserNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
204 No Content 表示处理成功,但响应体为空。因此成功分支不返回用户对象,也不声明 UserResponse 响应模型
以后前端接入删除接口时,收到 204 后不应继续调用 response.json,因为没有 JSON 内容可解析
本接口约定删除不存在的用户返回 404,所以重复删除同一个 ID,第一次成功后,第二次返回不存在
十三、通过 Swagger 检查行为
在 http://127.0.0.1:8000/docs 中选择接口,点击 Try it out,输入参数并执行
单条查询分别使用已有 ID、不存在的整数 ID 和 abc,对应 200、404、422
PATCH 使用已有用户,先只提交 display_name,确认返回的新昵称正确、用户名不变,再用 GET 检查保存结果。其他分支可按下表检查:
| 更新内容或条件 | 预期状态 |
|---|---|
| 合法部分更新 | 200 |
空对象 {} |
400 |
| 显式 null | 400 |
| 修改成其他用户的用户名 | 409 |
| 保持自己的用户名 | 200 |
| 字符串长度不合法 | 422 |
| 合法请求体但用户不存在 | 404 |
删除会实际移除数据库记录,使用专门创建的临时用户:创建后记下 ID,执行 DELETE 应返回 204 且响应体为空,再 GET 和再次 DELETE 都应返回 404
阶段结果
到这里,用户创建、列表查询、按 ID 查询、部分更新和删除已经形成完整的后端 CRUD。更新只修改请求实际提供的字段,删除成功返回空响应体,业务失败通过具体异常类型映射为不同的 HTTP 状态
但当前完成的是基础用户管理接口:
- 前端仍以创建表单为主,还没有完整的用户列表、编辑和删除页面
- 接口尚未加入登录认证和操作权限控制
- 用户模型还没有密码哈希字段,也没有登录流程
- 用户列表没有分页,并发用户名冲突引发的数据库约束异常尚未统一映射为 409
下一篇加入密码哈希和 JWT 登录认证,为后续 AI 接口建立用户身份校验