一个后端项目从什么时候开始变得像真实项目?
不是写出第一个接口的时候。
而是你开始处理这些问题的时候:
- 用户密码不能明文存
- 登录后要有身份凭证
- 受保护接口要知道当前用户是谁
- 接口返回格式要统一
- 数据库异常不能直接炸给前端
- 参数错、没登录、没权限、资源不存在,要有清楚的错误语义
这些东西看起来不像业务功能,但它们决定项目能不能继续长大。
这篇我们用 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 控制响应字段
-> 统一响应
-> 全局异常处理
把这条线打通之后,后面的收藏、浏览历史、个人中心才有基础。
下一篇,我们继续看收藏和历史。
这两个模块最值得学的不是接口数量,而是关系表怎么设计,唯一约束怎么兜底,浏览历史为什么更适合更新而不是重复插入。