🚀 FastAPI 学习传送门
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 中导入
- 打开 Apifox,新建或进入一个项目。
- 点击左侧目录树旁的
+,选择"导入";也可以进入"项目设置 → 导入数据"。 - 选择"手动导入 → URL 导入"。
- 填写
http://127.0.0.1:8000/openapi.json。 - 查看导入预览,确认接口和数据模型后完成导入。
📌 这里必须填写 /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 联调。真正需要记住的不是每一行代码,而是让不同模块各司其职,并始终守住密码不明文存储、响应不泄露密码、受保护接口先验证身份这三条规则。有问题欢迎讨论和指出。👋