学习 FastAPI 的 Day 4:完成用户系统与接口联调(完结)

🚀 FastAPI 学习传送门

Day 1:看懂接口与请求流程

Day 2:用异步 ORM 完成图书增删改查

Day 3:企业级目录与数据库迁移

Day 4:完成用户系统与接口联调(当前文章)

📦 GitHub 源码: dragonxjy/FastAPI

来到系列最后一天,我们把注册、登录、获取资料、修改资料和修改密码串成一条完整链路,最后将 FastAPI 接口导入 Apifox 进行调试。前面已经讲过依赖注入、异步 ORM 和目录管理,本篇只保留用户功能中最关键的代码。

一、相关目录 🗂️

text 复制代码
app/
├── api/v1/
│   └── routers.py                  # 汇总用户路由
├── modules/system/user/
│   ├── controller.py              # 接收请求、返回响应
│   ├── service.py                 # 组织业务流程
│   ├── crud.py                    # 操作数据库
│   ├── model.py                   # 用户表与令牌表
│   └── schema.py                  # 请求和响应模型
├── utils/
│   ├── auth.py                    # 解析 Token、获取当前用户
│   └── password_util.py           # 密码哈希与验证
├── core/database.py               # 提供数据库会话
└── common/response.py             # 统一响应格式

阅读主线只有一条:

Controller 接收请求 → Service 处理业务 → CRUD 读写数据库

二、准备用户数据模型 🧱

数据库使用两张表:User 保存账号和资料,UserToken 保存登录令牌和过期时间。密码字段存放的是哈希值,不是明文密码。

接口通过 Pydantic 模型限制输入和输出:

python 复制代码
from typing import Optional

from pydantic import BaseModel, ConfigDict, Field


class RegisterUser(BaseModel):
    username: str
    password: str


class LoginUser(BaseModel):
    username: str
    password: str


class UserInfoBase(BaseModel):
    nickname: Optional[str] = Field(None, max_length=50, description="昵称")
    avatar: Optional[str] = Field(None, max_length=255, description="头像URL")
    gender: Optional[str] = Field(None, max_length=10, description="性别")
    bio: Optional[str] = Field(None, max_length=500, description="个人简介")


class UserInfoResponse(UserInfoBase):
    id: int
    username: str
    model_config = ConfigDict(from_attributes=True)


class UserAuthResponse(BaseModel):
    token: str
    user_info: UserInfoResponse = Field(..., alias="userInfo")
    model_config = ConfigDict(populate_by_name=True, from_attributes=True)


class UserUpdateRequest(BaseModel):
    nickname: str = None
    avatar: str = None
    gender: str = None
    bio: str = None
    phone: str = None


class UserChangePasswordRequest(BaseModel):
    old_password: str = Field(..., alias="oldPassword", description="旧密码")
    new_password: str = Field(
        ...,
        min_length=6,
        alias="newPassword",
        description="新密码",
    )

UserInfoResponse 没有声明 password,因此接口返回用户信息时不会带出密码。

三、注册:创建用户并签发 Token 📝

注册顺序:检查用户名 → 哈希密码 → 新增用户 → 生成 Token。

1. Controller 接收请求

python 复制代码
@UserRouter.post("/register")
async def register(
    users: RegisterUser,
    db: AsyncSession = Depends(get_database),
):
    # 注册流程交给 Service。
    user, token = await get_register_service(users, db)
    response_data = UserAuthResponse(
        token=token,
        user_info=UserInfoResponse.model_validate(user),
    )
    return Success_response(message="注册成功", data=response_data)

2. Service 安排注册步骤

python 复制代码
async def get_register_service(users_data: RegisterUser, db: AsyncSession):
    # 注册前先检查用户名。
    existing_user = await users.get_user_by_username_api(db, users_data.username)
    if existing_user:
        raise HTTPException(status_code=400, detail="用户已存在")

    # 创建用户后生成 Token,注册完成即可登录。
    user = await users.create_user_api(db, users_data)
    token = await users.create_token_api(db, user.id)
    return user, token

3. CRUD 保存用户

python 复制代码
from sqlalchemy import select

from app.utils.password_util import get_password_hash


async def get_user_by_username_api(db: AsyncSession, username: str):
    query = select(User).where(User.username == username)
    result = await db.execute(query)
    return result.scalar_one_or_none()


async def create_user_api(db: AsyncSession, user_data: RegisterUser):
    # 明文密码先生成哈希,再写入数据库。
    hashed_password = get_password_hash(user_data.password)
    user = User(username=user_data.username, password=hashed_password)
    db.add(user)
    await db.commit()
    # 重新读取自增 id 和默认字段。
    await db.refresh(user)
    return user

密码工具使用 pwdlib

python 复制代码
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()


def get_password_hash(password: str) -> str:
    return password_hash.hash(password)


def verify_password(plain_password: str, hashed_password: str) -> bool:
    return password_hash.verify(plain_password, hashed_password)

哈希不能反向解密。登录时只能把用户输入的密码与原哈希进行验证。

4. CRUD 创建登录令牌

python 复制代码
async def create_token_api(db: AsyncSession, user_id: int):
    # 这里使用数据库 Token,不是 JWT。
    token = str(uuid.uuid4())
    expires_at = datetime.now() + timedelta(days=settings.TOKEN_EXPIRE_DAYS)

    query = select(UserToken).where(UserToken.user_id == user_id)
    result = await db.execute(query)
    user_token = result.scalar_one_or_none()

    if user_token:
        # 再次登录时替换旧 Token。
        user_token.token = token
        user_token.expires_at = expires_at
    else:
        user_token = UserToken(
            user_id=user_id,
            token=token,
            expires_at=expires_at,
        )
        db.add(user_token)
        await db.commit()
    return token

四、登录:验证账号和密码 🔑

1. Controller 接收登录参数

python 复制代码
@UserRouter.post("/login")
async def login(users: LoginUser, db: AsyncSession = Depends(get_database)):
    user, token = await get_login_service(users, db)
    response_data = UserAuthResponse(
        token=token,
        user_info=UserInfoResponse.model_validate(user),
    )
    return Success_response(message="登录成功", data=response_data)

2. Service 验证账号和密码

python 复制代码
async def get_login_service(users_data: LoginUser, db: AsyncSession):
    user = await users.get_user_by_username_api(db, users_data.username)
    if not user:
        raise HTTPException(status_code=401, detail="用户名或密码错误")

    # 明文密码与数据库中的哈希进行验证。
    if not verify_password(users_data.password, user.password):
        raise HTTPException(status_code=401, detail="用户名或密码错误")

    token = await users.create_token_api(db, user.id)
    return user, token

3. CRUD 查询用户并更新 Token

这里复用注册部分的 get_user_by_username_api()create_token_api()。无论用户名不存在还是密码错误,都返回相同提示,避免暴露账号是否存在。

五、获取当前用户 🪪

登录成功后,客户端需要携带请求头:

http 复制代码
Authorization: Bearer 生成的Token

1. Controller 声明身份依赖

python 复制代码
@UserRouter.get("/info")
async def info(user: User = Depends(get_current_user)):
    user_info = await get_user_info_service(user)
    return Success_response(message="获取用户信息成功", data=user_info)

2. Service 验证 Token

鉴权工具只解析请求头,用户查询仍然交给 Service:

python 复制代码
def _extract_bearer_token(authorization: str) -> str:
    scheme, separator, token = authorization.strip().partition(" ")
    if separator and scheme.lower() == "bearer" and token.strip():
        return token.strip()
    raise HTTPException(status_code=401, detail="Authorization 请求头格式错误")


async def get_current_user(
    authorization: str = Header(..., alias="Authorization"),
    db: AsyncSession = Depends(get_database),
) -> User:
    token = _extract_bearer_token(authorization)
    return await get_user_by_token_service(db, token)
python 复制代码
async def get_user_by_token_service(db: AsyncSession, token: str) -> User:
    user = await users.get_user_by_token_api(db, token)
    if not user:
        raise HTTPException(status_code=401, detail="无效或已经过期的令牌")
    return user


async def get_user_info_service(user: User) -> UserInfoResponse:
    # 响应模型会过滤密码字段。
    return UserInfoResponse.model_validate(user)

3. CRUD 查询 Token 和用户

python 复制代码
async def get_user_by_token_api(db: AsyncSession, token: str):
    query = select(UserToken).where(UserToken.token == token)
    result = await db.execute(query)
    db_token = result.scalar_one_or_none()

    # Token 不存在或过期时,身份验证失败。
    if not db_token or db_token.expires_at < datetime.now():
        return None

    query = select(User).where(User.id == db_token.user_id)
    result = await db.execute(query)
    return result.scalar_one_or_none()

六、修改用户信息 ✏️

1. Controller 接收修改内容

python 复制代码
@UserRouter.put("/update")
async def update(
    user_data: UserUpdateRequest,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_database),
):
    updated_user = await update_user_service(db, user, user_data)
    return Success_response(
        message="更新用户信息成功",
        data=UserInfoResponse.model_validate(updated_user),
    )

2. Service 确定当前用户

python 复制代码
async def update_user_service(
    db: AsyncSession,
    user: User,
    user_data: UserUpdateRequest,
) -> User:
    # 使用 Token 对应的用户,不相信请求体中的身份信息。
    return await users.update_user_api(db, user.username, user_data)

3. CRUD 更新已提交字段

python 复制代码
async def update_user_api(
    db: AsyncSession,
    username: str,
    user_data: UserUpdateRequest,
):
    # 未提交和传入 None 的字段都不更新。
    update_data = user_data.model_dump(
        exclude_unset=True,
        exclude_none=True,
    )
    update_data.pop("id", None)
    if not update_data:
        raise HTTPException(status_code=400, detail="没有需要更新的字段")

    query = update(User).where(User.username == username).values(**update_data)
    result = await db.execute(query)
    await db.commit()

    if result.rowcount == 0:
        raise HTTPException(status_code=404, detail="用户不存在")
    return await get_user_by_username_api(db, username)

客户端只发送 {"nickname": "小明"} 时,其他资料会保持原值。

七、修改密码 🔒

1. Controller 接收新旧密码

python 复制代码
@UserRouter.put("/password")
async def update_password(
    password_data: UserChangePasswordRequest,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_database),
):
    await change_password_service(db, user, password_data)
    return Success_response(message="更新密码成功")

2. Service 处理修改结果

python 复制代码
async def change_password_service(
    db: AsyncSession,
    user: User,
    password_data: UserChangePasswordRequest,
) -> None:
    updated = await users.change_password_api(
        db,
        user,
        password_data.old_password,
        password_data.new_password,
    )
    if not updated:
        raise HTTPException(status_code=400, detail="旧密码错误")

3. CRUD 验证并保存新密码

python 复制代码
async def change_password_api(
    db: AsyncSession,
    user: User,
    old_password: str,
    new_password: str,
):
    if not verify_password(old_password, user.password):
        return False

    # 新密码同样只能保存哈希值。
    user.password = get_password_hash(new_password)
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return True

八、把 FastAPI 接口导入 Apifox 🧪

FastAPI 会自动生成 OpenAPI 数据,Apifox 可以直接读取,不需要逐个手动创建接口。

1. 启动 FastAPI

bash 复制代码
uv run uvicorn main:app --reload

浏览器打开下面两个地址:

text 复制代码
http://127.0.0.1:8000/docs          # 可视化接口文档
http://127.0.0.1:8000/openapi.json  # OpenAPI 数据直链

能正常打开 /openapi.json 后再进入下一步。

2. 在 Apifox 中导入

  1. 打开 Apifox,新建或进入一个项目。
  2. 点击左侧目录树旁的 +,选择"导入";也可以进入"项目设置 → 导入数据"。
  3. 选择"手动导入 → URL 导入"。
  4. 填写 http://127.0.0.1:8000/openapi.json
  5. 查看导入预览,确认接口和数据模型后完成导入。

📌 这里必须填写 /openapi.json,不能填写 /docs。前者是 JSON 数据,后者只是 Swagger UI 页面。Apifox 官方也明确要求 URL 导入使用 JSON 或 YAML 直链,详见 导入 OpenAPI(Swagger)数据

3. 配置环境并测试

在 Apifox 的环境设置中,将"前置 URL"设置为:

text 复制代码
http://127.0.0.1:8000

先调用注册或登录接口,复制响应中的 token。测试用户资料、修改资料和修改密码时,在请求头中加入:

http 复制代码
Authorization: Bearer 实际Token

4. 接口变化后重新同步

修改路由或 Schema 后,FastAPI 的 /openapi.json 会同步变化。再次导入同一个 URL,并选择"智能合并",可以尽量保留 Apifox 中已经补充的说明、Mock 规则和返回示例。需要长期同步时,也可以在"项目设置 → 导入数据 → 自动同步"中添加这个地址。

九、完整调用顺序 🧭

操作 调用顺序 关键检查
注册 Controller → Service → CRUD 用户名不能重复,密码必须哈希
登录 Controller → Service → CRUD 账号存在且密码正确
获取资料 鉴权工具 → Service → CRUD Token 存在且未过期
修改资料 Controller → Service → CRUD 只修改客户端提交的字段
修改密码 Controller → Service → CRUD 旧密码正确,新密码重新哈希

结语

到这里,FastAPI 入门系列暂时完结。四天内容从接口参数、异步 ORM,一直走到目录分层、用户认证和 Apifox 联调。真正需要记住的不是每一行代码,而是让不同模块各司其职,并始终守住密码不明文存储、响应不泄露密码、受保护接口先验证身份这三条规则。有问题欢迎讨论和指出。👋

相关推荐
Zane19941 小时前
String::compareTo凭什么能当参数传?方法引用的四种形式讲透
java·后端
renzao_ai1 小时前
本地 35B 大模型部署实战:Ollama 跑 Ornith-35B 全流程
python·llama·免费ai大模型
JavaGuide1 小时前
阿里 Qoder 又开源了一个专门给 Claude Code、Codex 做“体检”的项目
前端·后端
敲代码的嘎仔2 小时前
28届后端开发-百人小厂面试题
java·后端·面试·程序员·秋招·实习·转正
Bs_MoneyMagnet2 小时前
基于springboot+vue的爱心众筹系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·管理系统
AIFQuant2 小时前
Python股票实时价格告警系统:WebSocket订阅与REST快照实战
开发语言·python·websocket·a股行情
名字还没想好☜2 小时前
Java Collectors.groupingBy 进阶:多级分组、下游收集器与统计聚合一次搞定
java·windows·后端·python·spring
明月_清风2 小时前
本体论和本体建模的具体应用场景究竟是什么?
人工智能·后端