前端手摸手跑路之 AI 应用开发(七)

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.pyapp/services/users.pyapp/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 接口建立用户身份校验

相关推荐
data analyse 45639 分钟前
重复事件怎么去重:按请求ID、用户+时间窗还是会话聚合?
前端·数据分析
我也要在julius_bar里藏钱40 分钟前
【山竹记账后端】1.搭建后端项目
后端·ruby·rails
亿元程序员40 分钟前
让Codex直接生成PSD难吗?提示词其实很简单
前端
Moment42 分钟前
为什么越来越多开发者开始用 PostgreSQL?
前端·后端·面试
mmsx43 分钟前
MapLibre 实战 13|让比例尺显示 100m 而不是 347.2m:屏幕距离换算与两个易错点
android·前端·app
YHL44 分钟前
🚀 从 SPA 到 Next.js 全栈:一个大前端的 SEO 突围笔记
前端·后端
颜进强1 小时前
从零跑通一套 WorkBuddy Skill 骨架:【能跑通+代码】生成HTML报告实战
前端·后端·ai编程
data analyse 4561 小时前
能同时统计网站App小程序的分析平台怎么选?
前端·数据分析
Shinomiya1 小时前
Mysql之表的约束详解
后端