FastAPI用户模块:用户登录、Token、统一响应和异常处理,项目能不能像样就看这一篇

一个后端项目从什么时候开始变得像真实项目?

不是写出第一个接口的时候。

而是你开始处理这些问题的时候:

  • 用户密码不能明文存
  • 登录后要有身份凭证
  • 受保护接口要知道当前用户是谁
  • 接口返回格式要统一
  • 数据库异常不能直接炸给前端
  • 参数错、没登录、没权限、资源不存在,要有清楚的错误语义

这些东西看起来不像业务功能,但它们决定项目能不能继续长大。

这篇我们用 AI 掘金头条的用户模块,把认证、Token、统一响应和异常处理讲清楚。

本篇准备

这一篇沿用 FastAPI + SQLAlchemy 环境,并新增密码哈希依赖:

bash 复制代码
pip install fastapi uvicorn sqlalchemy aiomysql passlib[bcrypt]

课程项目使用 passlib + bcrypt,适合理解密码哈希流程。如果你是新开生产项目,也可以再对照 FastAPI 当前安全文档,评估是否改用 pwdlib + Argon2

1. 用户认证要解决什么问题

用户模块至少要做五件事:

  • 注册
  • 登录
  • 获取用户信息
  • 更新用户信息
  • 修改密码

这里最关键的是登录。

登录成功以后,前端需要拿到一个凭证。后续访问个人中心、收藏列表、浏览历史时,都带上这个凭证,后端据此判断当前用户是谁。

项目里用的是 Token 机制。

简化流程如下:
MySQL FastAPI 前端 用户 MySQL FastAPI 前端 用户 #mermaid-svg-ggpinrWMK1aj5tH8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ggpinrWMK1aj5tH8 .error-icon{fill:#552222;}#mermaid-svg-ggpinrWMK1aj5tH8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ggpinrWMK1aj5tH8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ggpinrWMK1aj5tH8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ggpinrWMK1aj5tH8 .marker.cross{stroke:#333333;}#mermaid-svg-ggpinrWMK1aj5tH8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ggpinrWMK1aj5tH8 p{margin:0;}#mermaid-svg-ggpinrWMK1aj5tH8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ggpinrWMK1aj5tH8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ggpinrWMK1aj5tH8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ggpinrWMK1aj5tH8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ggpinrWMK1aj5tH8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ggpinrWMK1aj5tH8 .sequenceNumber{fill:white;}#mermaid-svg-ggpinrWMK1aj5tH8 #sequencenumber{fill:#333;}#mermaid-svg-ggpinrWMK1aj5tH8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ggpinrWMK1aj5tH8 .messageText{fill:#333;stroke:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ggpinrWMK1aj5tH8 .labelText,#mermaid-svg-ggpinrWMK1aj5tH8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .loopText,#mermaid-svg-ggpinrWMK1aj5tH8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ggpinrWMK1aj5tH8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ggpinrWMK1aj5tH8 .noteText,#mermaid-svg-ggpinrWMK1aj5tH8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ggpinrWMK1aj5tH8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ggpinrWMK1aj5tH8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ggpinrWMK1aj5tH8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ggpinrWMK1aj5tH8 .actorPopupMenu{position:absolute;}#mermaid-svg-ggpinrWMK1aj5tH8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ggpinrWMK1aj5tH8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ggpinrWMK1aj5tH8 .actor-man circle,#mermaid-svg-ggpinrWMK1aj5tH8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ggpinrWMK1aj5tH8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入用户名和密码 POST /api/user/login 查询用户 校验密码哈希 生成并保存 Token 返回 Token 和用户信息 后续请求携带 Authorization 根据 Token 查询当前用户 返回受保护数据

这条线就是用户认证的主线。

2. 数据库设计,user 和 user_token

项目里用户相关主要有两张表。

用户表 user

sql 复制代码
CREATE TABLE `user` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  `password` VARCHAR(255) NOT NULL,
  `nickname` VARCHAR(50),
  `avatar` VARCHAR(255),
  `gender` ENUM('male', 'female', 'unknown') DEFAULT 'unknown',
  `bio` VARCHAR(500),
  `phone` VARCHAR(20),
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `username_UNIQUE` (`username`),
  UNIQUE INDEX `phone_UNIQUE` (`phone`)
);

Token 表 user_token

sql 复制代码
CREATE TABLE `user_token` (
  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` INT UNSIGNED NOT NULL,
  `token` VARCHAR(255) NOT NULL,
  `expires_at` TIMESTAMP NOT NULL,
  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `token_UNIQUE` (`token`),
  CONSTRAINT `fk_user_token_user`
    FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
    ON DELETE CASCADE ON UPDATE CASCADE
);

关系是:
#mermaid-svg-o2SykDURRZq8dPIy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-o2SykDURRZq8dPIy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-o2SykDURRZq8dPIy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-o2SykDURRZq8dPIy .error-icon{fill:#552222;}#mermaid-svg-o2SykDURRZq8dPIy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-o2SykDURRZq8dPIy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-o2SykDURRZq8dPIy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-o2SykDURRZq8dPIy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-o2SykDURRZq8dPIy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-o2SykDURRZq8dPIy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-o2SykDURRZq8dPIy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-o2SykDURRZq8dPIy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-o2SykDURRZq8dPIy .marker.cross{stroke:#333333;}#mermaid-svg-o2SykDURRZq8dPIy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-o2SykDURRZq8dPIy p{margin:0;}#mermaid-svg-o2SykDURRZq8dPIy .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-o2SykDURRZq8dPIy .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-o2SykDURRZq8dPIy .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-o2SykDURRZq8dPIy .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-o2SykDURRZq8dPIy .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-o2SykDURRZq8dPIy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-o2SykDURRZq8dPIy .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-o2SykDURRZq8dPIy .node rect,#mermaid-svg-o2SykDURRZq8dPIy .node circle,#mermaid-svg-o2SykDURRZq8dPIy .node ellipse,#mermaid-svg-o2SykDURRZq8dPIy .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-o2SykDURRZq8dPIy .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-o2SykDURRZq8dPIy .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-o2SykDURRZq8dPIy .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-o2SykDURRZq8dPIy .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-o2SykDURRZq8dPIy .edgeLabel .label text{fill:#333;}#mermaid-svg-o2SykDURRZq8dPIy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} owns
USER
int
id
PK
string
username
UK
string
password
string
nickname
string
phone
UK
USER_TOKEN
int
id
PK
int
user_id
FK
string
token
UK
datetime
expires_at

user 存用户资料。

user_token 存登录凭证。

用户删除时,Token 也跟着删除。

3. 密码不能明文存

注册时,绝对不要把用户密码原样写进数据库。

应该存密码哈希。

课程里使用的是 passlib + bcrypt

python 复制代码
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

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

注册时:

python 复制代码
hashed = hash_password(request.password)
user = User(username=request.username, password=hashed)
db.add(user)
await db.commit()

登录时:

python 复制代码
if not verify_password(request.password, user.password):
    raise HTTPException(status_code=401, detail="用户名或密码错误")

这里要强调一个细节。

FastAPI 官方较新的安全教程示例已经推荐 pwdlib 搭配 Argon2,用于新的密码哈希示例;passlib 仍然常用于兼容旧系统和教学项目。课程使用 passlib + bcrypt 没问题,但如果你新开生产项目,建议查当前官方安全文档,再定团队方案。

另外,如果继续使用 passlib,依赖版本要锁定并测试。密码库和底层 bcrypt 包版本不匹配时,可能出现警告或运行问题。

4. 注册流程

注册接口大概做这些事:
#mermaid-svg-rj0pAcJufntSUGdH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rj0pAcJufntSUGdH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rj0pAcJufntSUGdH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rj0pAcJufntSUGdH .error-icon{fill:#552222;}#mermaid-svg-rj0pAcJufntSUGdH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rj0pAcJufntSUGdH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rj0pAcJufntSUGdH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rj0pAcJufntSUGdH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rj0pAcJufntSUGdH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rj0pAcJufntSUGdH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rj0pAcJufntSUGdH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rj0pAcJufntSUGdH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rj0pAcJufntSUGdH .marker.cross{stroke:#333333;}#mermaid-svg-rj0pAcJufntSUGdH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rj0pAcJufntSUGdH p{margin:0;}#mermaid-svg-rj0pAcJufntSUGdH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rj0pAcJufntSUGdH .cluster-label text{fill:#333;}#mermaid-svg-rj0pAcJufntSUGdH .cluster-label span{color:#333;}#mermaid-svg-rj0pAcJufntSUGdH .cluster-label span p{background-color:transparent;}#mermaid-svg-rj0pAcJufntSUGdH .label text,#mermaid-svg-rj0pAcJufntSUGdH span{fill:#333;color:#333;}#mermaid-svg-rj0pAcJufntSUGdH .node rect,#mermaid-svg-rj0pAcJufntSUGdH .node circle,#mermaid-svg-rj0pAcJufntSUGdH .node ellipse,#mermaid-svg-rj0pAcJufntSUGdH .node polygon,#mermaid-svg-rj0pAcJufntSUGdH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rj0pAcJufntSUGdH .rough-node .label text,#mermaid-svg-rj0pAcJufntSUGdH .node .label text,#mermaid-svg-rj0pAcJufntSUGdH .image-shape .label,#mermaid-svg-rj0pAcJufntSUGdH .icon-shape .label{text-anchor:middle;}#mermaid-svg-rj0pAcJufntSUGdH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rj0pAcJufntSUGdH .rough-node .label,#mermaid-svg-rj0pAcJufntSUGdH .node .label,#mermaid-svg-rj0pAcJufntSUGdH .image-shape .label,#mermaid-svg-rj0pAcJufntSUGdH .icon-shape .label{text-align:center;}#mermaid-svg-rj0pAcJufntSUGdH .node.clickable{cursor:pointer;}#mermaid-svg-rj0pAcJufntSUGdH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rj0pAcJufntSUGdH .arrowheadPath{fill:#333333;}#mermaid-svg-rj0pAcJufntSUGdH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rj0pAcJufntSUGdH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rj0pAcJufntSUGdH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rj0pAcJufntSUGdH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rj0pAcJufntSUGdH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rj0pAcJufntSUGdH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rj0pAcJufntSUGdH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rj0pAcJufntSUGdH .cluster text{fill:#333;}#mermaid-svg-rj0pAcJufntSUGdH .cluster span{color:#333;}#mermaid-svg-rj0pAcJufntSUGdH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rj0pAcJufntSUGdH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rj0pAcJufntSUGdH rect.text{fill:none;stroke-width:0;}#mermaid-svg-rj0pAcJufntSUGdH .icon-shape,#mermaid-svg-rj0pAcJufntSUGdH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rj0pAcJufntSUGdH .icon-shape p,#mermaid-svg-rj0pAcJufntSUGdH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rj0pAcJufntSUGdH .icon-shape .label rect,#mermaid-svg-rj0pAcJufntSUGdH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rj0pAcJufntSUGdH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rj0pAcJufntSUGdH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rj0pAcJufntSUGdH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

POST /api/user/register
校验 username/password
查询用户名是否已存在
存在吗
返回错误
密码哈希
写入 user 表
返回注册成功

简化代码:

python 复制代码
async def register_user(db: AsyncSession, request: UserRequest):
    result = await db.execute(
        select(User).where(User.username == request.username)
    )
    exists = result.scalar_one_or_none()

    if exists:
        raise HTTPException(status_code=400, detail="用户名已存在")

    user = User(
        username=request.username,
        password=hash_password(request.password)
    )
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

这里用户名唯一不应该只靠代码判断。

数据库里也要有唯一索引。

因为并发请求可能同时通过代码层检查,最后还是数据库唯一约束兜底。

5. 登录流程

登录流程是:
#mermaid-svg-lLRJH9fx4nHi5neb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lLRJH9fx4nHi5neb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lLRJH9fx4nHi5neb .error-icon{fill:#552222;}#mermaid-svg-lLRJH9fx4nHi5neb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lLRJH9fx4nHi5neb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lLRJH9fx4nHi5neb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lLRJH9fx4nHi5neb .marker.cross{stroke:#333333;}#mermaid-svg-lLRJH9fx4nHi5neb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lLRJH9fx4nHi5neb p{margin:0;}#mermaid-svg-lLRJH9fx4nHi5neb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lLRJH9fx4nHi5neb .cluster-label text{fill:#333;}#mermaid-svg-lLRJH9fx4nHi5neb .cluster-label span{color:#333;}#mermaid-svg-lLRJH9fx4nHi5neb .cluster-label span p{background-color:transparent;}#mermaid-svg-lLRJH9fx4nHi5neb .label text,#mermaid-svg-lLRJH9fx4nHi5neb span{fill:#333;color:#333;}#mermaid-svg-lLRJH9fx4nHi5neb .node rect,#mermaid-svg-lLRJH9fx4nHi5neb .node circle,#mermaid-svg-lLRJH9fx4nHi5neb .node ellipse,#mermaid-svg-lLRJH9fx4nHi5neb .node polygon,#mermaid-svg-lLRJH9fx4nHi5neb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lLRJH9fx4nHi5neb .rough-node .label text,#mermaid-svg-lLRJH9fx4nHi5neb .node .label text,#mermaid-svg-lLRJH9fx4nHi5neb .image-shape .label,#mermaid-svg-lLRJH9fx4nHi5neb .icon-shape .label{text-anchor:middle;}#mermaid-svg-lLRJH9fx4nHi5neb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lLRJH9fx4nHi5neb .rough-node .label,#mermaid-svg-lLRJH9fx4nHi5neb .node .label,#mermaid-svg-lLRJH9fx4nHi5neb .image-shape .label,#mermaid-svg-lLRJH9fx4nHi5neb .icon-shape .label{text-align:center;}#mermaid-svg-lLRJH9fx4nHi5neb .node.clickable{cursor:pointer;}#mermaid-svg-lLRJH9fx4nHi5neb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lLRJH9fx4nHi5neb .arrowheadPath{fill:#333333;}#mermaid-svg-lLRJH9fx4nHi5neb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lLRJH9fx4nHi5neb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lLRJH9fx4nHi5neb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lLRJH9fx4nHi5neb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lLRJH9fx4nHi5neb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lLRJH9fx4nHi5neb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lLRJH9fx4nHi5neb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lLRJH9fx4nHi5neb .cluster text{fill:#333;}#mermaid-svg-lLRJH9fx4nHi5neb .cluster span{color:#333;}#mermaid-svg-lLRJH9fx4nHi5neb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lLRJH9fx4nHi5neb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lLRJH9fx4nHi5neb rect.text{fill:none;stroke-width:0;}#mermaid-svg-lLRJH9fx4nHi5neb .icon-shape,#mermaid-svg-lLRJH9fx4nHi5neb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lLRJH9fx4nHi5neb .icon-shape p,#mermaid-svg-lLRJH9fx4nHi5neb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lLRJH9fx4nHi5neb .icon-shape .label rect,#mermaid-svg-lLRJH9fx4nHi5neb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lLRJH9fx4nHi5neb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lLRJH9fx4nHi5neb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lLRJH9fx4nHi5neb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



POST /api/user/login
按 username 查询用户
用户存在吗
401 登录失败
verify_password
密码正确吗
生成 token
写入 user_token
返回 token + user_info

Token 可以用 uuid 生成:

python 复制代码
import uuid
from datetime import datetime, timedelta

token = str(uuid.uuid4())
expires_at = datetime.now() + timedelta(days=7)

再保存:

python 复制代码
user_token = UserToken(
    user_id=user.id,
    token=token,
    expires_at=expires_at
)
db.add(user_token)
await db.commit()

返回给前端:

json 复制代码
{
  "code": 200,
  "message": "登录成功",
  "data": {
    "token": "xxx",
    "userInfo": {
      "id": 1,
      "username": "tom"
    }
  }
}

6. get_current_user,认证依赖的核心

登录后,前端请求受保护接口时带请求头:

http 复制代码
Authorization: Bearer <token>

项目里的 get_current_user 大概做这些事:

python 复制代码
from fastapi import Depends, Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession

async def get_current_user(
    authorization: str = Header(None),
    db: AsyncSession = Depends(get_db)
):
    if not authorization:
        raise HTTPException(status_code=401, detail="未登录")

    scheme, _, raw_token = authorization.partition(" ")
    if scheme.lower() != "bearer" or not raw_token:
        raise HTTPException(status_code=401, detail="认证格式错误")

    token = await get_token(db, raw_token)

    if token is None:
        raise HTTPException(status_code=401, detail="登录已失效")

    return token.user

路由里使用:

python 复制代码
@router.get("/info")
async def get_user_info(
    current_user: User = Depends(get_current_user)
):
    user_info = UserInfoResponse.model_validate(current_user)
    return success_response(data=user_info)

这里的 UserInfoResponse 会在后面的响应模型小节定义。

这样业务接口不需要自己解析 Token。

它只要声明:

我需要当前用户。

FastAPI 就会先执行依赖,把用户对象交给它。

注意,这里不要把 ORM 用户对象原样返回给前端。

即使密码字段是哈希,也不应该出现在响应里。先转成 UserInfoResponse,再交给统一响应函数,字段边界会清楚很多。

7. 统一响应格式

项目里成功响应统一成:

json 复制代码
{
  "code": 200,
  "message": "操作成功",
  "data": {}
}

对应工具函数:

python 复制代码
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder

def success_response(message="操作成功", data=None, code=200):
    return JSONResponse(
        content=jsonable_encoder({
            "code": code,
            "message": message,
            "data": data
        })
    )

统一响应的好处是前端处理简单。

所有接口都可以按:

js 复制代码
if (res.data.code === 200) {
  // 使用 res.data.data
}

但要注意,不要把 HTTP 状态码完全废掉。

比如未登录,最好仍然返回 HTTP 401。

否则浏览器、网关、监控系统都无法正确理解请求结果。

8. 全局异常处理

项目里注册了全局异常处理器:

python 复制代码
from fastapi import FastAPI, HTTPException
from sqlalchemy.exc import IntegrityError, SQLAlchemyError

def register_exception_handlers(app: FastAPI):
    app.add_exception_handler(HTTPException, http_exception_handler)
    app.add_exception_handler(IntegrityError, integrity_error_handler)
    app.add_exception_handler(SQLAlchemyError, sqlalchemy_error_handler)
    app.add_exception_handler(Exception, general_exception_handler)

这样做的好处是,异常不会以杂乱形式直接暴露给前端。

不同异常可以统一包装成稳定结构。
#mermaid-svg-hgS8heV1CX9jx0kF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hgS8heV1CX9jx0kF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hgS8heV1CX9jx0kF .error-icon{fill:#552222;}#mermaid-svg-hgS8heV1CX9jx0kF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hgS8heV1CX9jx0kF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hgS8heV1CX9jx0kF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hgS8heV1CX9jx0kF .marker.cross{stroke:#333333;}#mermaid-svg-hgS8heV1CX9jx0kF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hgS8heV1CX9jx0kF p{margin:0;}#mermaid-svg-hgS8heV1CX9jx0kF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-hgS8heV1CX9jx0kF .cluster-label text{fill:#333;}#mermaid-svg-hgS8heV1CX9jx0kF .cluster-label span{color:#333;}#mermaid-svg-hgS8heV1CX9jx0kF .cluster-label span p{background-color:transparent;}#mermaid-svg-hgS8heV1CX9jx0kF .label text,#mermaid-svg-hgS8heV1CX9jx0kF span{fill:#333;color:#333;}#mermaid-svg-hgS8heV1CX9jx0kF .node rect,#mermaid-svg-hgS8heV1CX9jx0kF .node circle,#mermaid-svg-hgS8heV1CX9jx0kF .node ellipse,#mermaid-svg-hgS8heV1CX9jx0kF .node polygon,#mermaid-svg-hgS8heV1CX9jx0kF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hgS8heV1CX9jx0kF .rough-node .label text,#mermaid-svg-hgS8heV1CX9jx0kF .node .label text,#mermaid-svg-hgS8heV1CX9jx0kF .image-shape .label,#mermaid-svg-hgS8heV1CX9jx0kF .icon-shape .label{text-anchor:middle;}#mermaid-svg-hgS8heV1CX9jx0kF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hgS8heV1CX9jx0kF .rough-node .label,#mermaid-svg-hgS8heV1CX9jx0kF .node .label,#mermaid-svg-hgS8heV1CX9jx0kF .image-shape .label,#mermaid-svg-hgS8heV1CX9jx0kF .icon-shape .label{text-align:center;}#mermaid-svg-hgS8heV1CX9jx0kF .node.clickable{cursor:pointer;}#mermaid-svg-hgS8heV1CX9jx0kF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hgS8heV1CX9jx0kF .arrowheadPath{fill:#333333;}#mermaid-svg-hgS8heV1CX9jx0kF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hgS8heV1CX9jx0kF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hgS8heV1CX9jx0kF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hgS8heV1CX9jx0kF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hgS8heV1CX9jx0kF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hgS8heV1CX9jx0kF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hgS8heV1CX9jx0kF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hgS8heV1CX9jx0kF .cluster text{fill:#333;}#mermaid-svg-hgS8heV1CX9jx0kF .cluster span{color:#333;}#mermaid-svg-hgS8heV1CX9jx0kF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hgS8heV1CX9jx0kF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hgS8heV1CX9jx0kF rect.text{fill:none;stroke-width:0;}#mermaid-svg-hgS8heV1CX9jx0kF .icon-shape,#mermaid-svg-hgS8heV1CX9jx0kF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hgS8heV1CX9jx0kF .icon-shape p,#mermaid-svg-hgS8heV1CX9jx0kF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hgS8heV1CX9jx0kF .icon-shape .label rect,#mermaid-svg-hgS8heV1CX9jx0kF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hgS8heV1CX9jx0kF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hgS8heV1CX9jx0kF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hgS8heV1CX9jx0kF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务代码抛异常
异常类型
HTTPException
IntegrityError
SQLAlchemyError
Exception
HTTP 异常处理器
唯一约束/外键异常处理器
数据库异常处理器
兜底异常处理器
统一 JSON 错误响应

其中 IntegrityError 很常见。

比如用户名重复、手机号重复、收藏重复,都可能触发数据库唯一约束错误。

业务层可以先查,但数据库层仍然要兜底。

9. 响应模型要避免泄漏敏感字段

用户信息响应不要直接返回 ORM 对象。

应该定义 schema:

python 复制代码
from pydantic import BaseModel, ConfigDict

class UserInfoResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    username: str
    nickname: str | None = None
    avatar: str | None = None
    gender: str | None = None
    bio: str | None = None
    phone: str | None = None

注意,不包含 password

登录响应:

python 复制代码
class UserAuthResponse(BaseModel):
    token: str
    user_info: UserInfoResponse

这样接口输出结构明确,也能减少敏感字段误返回的风险。

10. 课程项目里的几个实战提醒

第一,密码不能明文存。

这不是最佳实践,这是底线。

第二,Token 要有过期时间。

永不过期的 Token,泄漏后风险很大。

第三,认证逻辑用 Depends 很合适。

因为业务接口最终需要的是 current_user,不是一个布尔值。

第四,统一响应格式不要替代 HTTP 状态码。

code 字段可以有,但 401、403、404 这些状态码仍然有价值。

第五,数据库唯一约束不能省。

注册前查用户名只是用户体验,真正保证唯一的是数据库。

11. 小结

用户模块不是简单的注册登录。

它背后其实是一整套项目基础设施:

text 复制代码
密码哈希
-> 登录校验
-> Token 存储
-> Depends 获取当前用户
-> Pydantic 控制响应字段
-> 统一响应
-> 全局异常处理

把这条线打通之后,后面的收藏、浏览历史、个人中心才有基础。

下一篇,我们继续看收藏和历史。

这两个模块最值得学的不是接口数量,而是关系表怎么设计,唯一约束怎么兜底,浏览历史为什么更适合更新而不是重复插入。

参考资料