作为前端开发,第一次从零构建完整的后端认证系统,记录一些思考和收获。
字数:约 1,900 字 | 阅读时间:5 分钟
前言 ✨
经过前面的学习和实践,我终于完成了一个完整的用户认证系统。从注册、登录到权限管理,从配额系统到用户信息管理,每个接口都已跑通并通过测试。
作为一个前端开发,以前只是调用后端提供的接口,从来没想过这些接口背后的设计逻辑。这次亲手实现了一遍,才真正理解了很多之前"理所当然"的东西。
这篇文章不讲具体代码实现,而是想梳理一下思路,总结一些经验,记录一些思考。重在复盘,而非实战。
让我们开始吧!
一、用户系统的核心架构 🏗️
1.1 数据模型设计
User Schema 是整个系统的基础。一开始我以为用户表很简单,不就是用户名、邮箱、密码吗?但实际设计时才发现要考虑的东西很多:
User 表结构:
├── 基础信息(username、email、password)
├── 配额管理(credits、freeQuota、paidQuota)
├── 状态标识(isActive、isVip)
├── 第三方登录(wechatId)
└── 时间戳(createdAt、updatedAt)我学到的设计原则:
- ✅ 唯一索引:email、username 必须唯一(这个很重要,不然会有重复注册的问题)
- ✅ 默认值:新用户赠送初始配额(产品思维,让用户先体验)
- ✅ 扩展性:预留第三方登录字段(虽然现在用不到,但以后可能要接微信登录)
💭 我的思考:以前做前端,表单验证只是为了"不让用户乱填"。现在做后端才明白,数据库层面的约束才是真正的防线。前端验证可以绕过,但数据库的唯一索引绕不过。
1.2 认证流程设计
JWT vs Session:为什么选择 JWT?
学习之前,我只知道"登录后会有个 Token",但不知道为什么要用 Token。现在理解了:
| 特性 | Session | JWT |
|---|---|---|
| 存储位置 | 服务器 | 客户端 |
| 扩展性 | 差(需要共享 Session) | 好(无状态) |
| 性能 | 需要查询存储 | 直接验证签名 |
| 适用场景 | 传统应用 | 现代应用、微服务 |
JWT 的三部分结构:
Header(头部):算法和类型
Payload(负载):用户信息
Signature(签名):验证完整性我的理解:
- JWT 是无状态的,服务器不需要存储 Token(这点很关键,意味着可以水平扩展)
- Token 包含用户信息,减少数据库查询(以前不理解为什么每次请求都要带 Token,现在明白了)
- 通过签名验证,确保 Token 未被篡改(这是安全的核心)
💭 我的疑问:一开始我不理解,为什么 Token 可以放在客户端,不怕被篡改吗?后来才明白,Token 虽然可以被解码看到内容,但因为有签名,任何修改都会导致验证失败。就像一封有蜡封的信,你可以看,但一旦拆开就能发现。
二、安全性:这是我最大的收获 🔒
2.1 密码安全
永远不要明文存储密码!
这个道理我以前就知道,但不知道具体怎么做。现在学会了 bcrypt:
用户输入:password123
↓
bcrypt 加密(加盐 + 哈希)
↓
存储:$2a$10$N9qo8uLO...bcrypt 的三个优势:
- 加盐:每次加密结果不同,防止彩虹表攻击
- 慢速:故意设计得慢,防止暴力破解
- 自适应:可以调整计算复杂度
我学到的最佳实践:
- ✅ 使用 bcrypt 加密密码
- ✅ 验证时使用
comparePassword() - ✅ 永远不返回密码给客户端
- ❌ 不使用弱加密算法(MD5、SHA1)
💭 我的感悟:以前做前端表单验证,总觉得"密码至少 6 位"这种规则很烦。现在做后端才明白,这些规则都是为了安全。而且后端的验证更重要,因为前端验证可以被绕过。
2.2 Token 安全
Token 设计的四个原则(这是我踩过坑后总结的):
1. 设置过期时间
expiresIn: '7d' // 7 天后失效- 一开始我设置了 30 天,后来发现这样不安全
- 现在改成 7 天,平衡用户体验和安全性
2. 从 Authorization 头提取
Authorization: Bearer <token>- 不要在 URL 中传递 Token(会被记录到日志里)
- 这个是我看别人的代码学到的
3. 验证签名
JwtService.verify(token) // 验证签名是否有效- 确保 Token 未被篡改
- 使用强密钥(secret),不要用 '123456' 这种
4. 不在 Token 中放敏感信息
// ✅ 可以放
{ userId: '123', username: 'alice' }
// ❌ 不要放
{ userId: '123', password: '...' }💭 我的教训:一开始我把用户的所有信息都放 Token 里了,包括邮箱、手机号。后来才知道 Token 是可以被解码的(虽然不能篡改)。现在我只放必要的信息:userId 和 username。
2.3 用户隐私保护
三个核心原则(这是我最容易忽略的):
1. 从 req.user 获取当前用户
// ✅ 正确
const userId = req.user.userId;
// ❌ 错误:允许用户访问任意用户数据
const userId = req.params.userId;这个坑我踩过!一开始我写了个"修改用户信息"的接口,直接从 URL 参数拿 userId。结果任何人都可以修改别人的信息。后来才明白,要从 req.user 拿当前登录用户的 ID。
2. 不返回敏感字段
// 使用 select 排除密码
.select('-password')
// 或在 Schema 中设置
@Prop({ select: false })
password: string;3. 检查权限
// 更新用户信息时,确保是本人
if (req.user.userId !== targetUserId) {
throw new ForbiddenException('无权操作');
}💭 我的反思:以前做前端,总觉得"用户只能看到自己的数据"是理所当然的。现在做后端才知道,这些都需要代码来保证。前端的权限控制只是 UI 层面的,真正的权限控制在后端。
三、配额系统:这个把我难住了 ⚡
3.1 为什么需要原子操作?
这是我遇到的最难理解的概念。一开始我写的代码是这样的:
// ❌ 我最初的错误写法
const user = await this.userModel.findById(userId);
if (user.quota > 0) {
user.quota--;
await user.save();
}看起来没问题对吧?但测试的时候发现,如果两个请求同时来,配额会变成负数!
问题场景:
用户剩余配额:1 次
两个请求同时到达:
请求 A:检查配额 → 1 > 0 → 通过 ✅
请求 B:检查配额 → 1 > 0 → 通过 ✅
请求 A:扣减配额 → 0
请求 B:扣减配额 → -1 ❌(超额了!)后来学到了原子操作:
// ✅ 正确的写法
await this.userModel.findByIdAndUpdate(
userId,
{ $inc: { quota: -1 } },
{ new: true }
);我的理解:
- MongoDB 的
$inc操作是原子的 - 数据库层面保证不会超额
- 高并发场景下必须使用
💭 我的感悟:这个问题让我意识到,后端开发和前端开发的思维方式真的不一样。前端很少考虑并发问题,但后端必须考虑。这也是为什么后端要写测试用例,模拟并发请求。
3.2 配额扣减的完整流程
经过几次迭代,我总结出了这个流程:
1. 检查配额是否充足
↓
2. 调用 AI 服务
↓
3. AI 调用成功?
├─ 是 → 扣减配额 + 记录消费
└─ 否 → 不扣减,返回错误我学到的最佳实践:
- ✅ 先检查后扣减(不要扣了才发现不够)
- ✅ 只有成功才扣减(AI 调用失败不能扣钱)
- ✅ 记录每次消费历史(方便用户查询,也方便排查问题)
- ✅ 使用原子操作防止竞态(这个太重要了)
四、认证流程:串起来才理解 🔄
4.1 完整的认证链路
一开始学的时候,每个概念都是独立的。现在把它们串起来,才真正理解了整个流程:
1. 用户注册
POST /user/register
├── DTO 验证数据格式
├── 检查邮箱是否重复
├── bcrypt 加密密码
└── 保存到数据库
2. 用户登录
POST /user/login
├── 查找用户
├── bcrypt 比对密码
├── 生成 JWT Token
└── 返回 Token
3. 访问受保护接口
GET /user/info
Authorization: Bearer <token>
├── JwtAuthGuard 拦截
├── 提取 Token
├── JwtStrategy 验证
├── 提取用户信息到 req.user
└── 业务逻辑处理4.2 各组件的职责
这是我花了很长时间才理解的:
DTO → 定义数据结构,验证格式
ValidationPipe → 自动验证 DTO
Controller → 接收请求,调用 Service
Service → 业务逻辑,调用数据库
JwtService → 生成和验证 Token
JwtStrategy → Passport 策略,提取用户信息
JwtAuthGuard → 守卫,保护需要认证的接口
ResponseInterceptor → 统一响应格式我的理解:
- 每一层只做自己的事(关注点分离)
- Controller 不写业务逻辑(只做转发)
- Service 不关心 HTTP(只关心业务)
💭 我的感悟:这种分层的思想,其实前端也有(比如 React 的容器组件和展示组件)。但后端的分层更严格,每一层的职责更明确。这样做的好处是,改一个地方不会影响其他地方。
五、最佳实践总结 🎯
5.1 代码组织
这是我参考优秀项目总结的结构:
user/
├── dto/ # 数据传输对象
│ ├── register.dto.ts
│ ├── login.dto.ts
│ └── update-user.dto.ts
├── user.schema.ts # 数据模型
├── user.service.ts # 业务逻辑
├── user.controller.ts # HTTP 接口
└── user.module.ts # 模块配置
auth/
├── jwt.strategy.ts # JWT 策略
├── jwt-auth.guard.ts # JWT 守卫
└── public.decorator.ts # 公开接口装饰器5.2 命名规范
这是我自己总结的,方便以后查找:
方法命名:
get*:查询方法create*:创建方法update*:更新方法check*:检查方法*Quota:配额相关方法
参数命名:
userId:用户 IDdto:数据传输对象req:请求对象
5.3 安全检查清单
这是我每次写完代码都会检查的:
密码安全:
- 使用 bcrypt 加密
- 不返回密码给客户端
- 使用 comparePassword() 验证
Token 安全:
- 设置过期时间
- 从 Authorization 头提取
- 验证签名
- 不在 Token 中放敏感信息
用户隐私:
- 从 req.user 获取当前用户
- 不返回敏感字段
- 检查操作权限
配额安全:
- 使用原子操作
- 先检查后扣减
- 记录消费历史
- 只在成功时扣减
六、一些思考 💭
Q1: 用户忘记密码怎么办?
这个功能我还没实现,但已经有思路了:
- 用户输入邮箱
- 后端发送重置链接(带 Token)
- 用户点击链接,输入新密码
- 验证 Token,重置密码
关键是这个 Token 要设置短期过期(比如 15 分钟),而且只能用一次。
Q2: Token 被盗了怎么办?
这个问题我想了很久,目前的防护方案:
- 使用 HTTPS 加密传输(这个是必须的)
- 设置短期 Token(7 天)
- 可选:IP 绑定、设备绑定(但会影响用户体验)
说实话,如果 Token 真的被盗了,很难完全防止。只能尽量降低风险。
Q3: 如何实现登出?
这个我纠结了很久:
简单方案:客户端删除 Token(我现在用的) 复杂方案:维护 Token 黑名单(但这样就失去了 JWT 无状态的优势)
最后我选择了简单方案,设置短期 Token,不需要登出机制。
七、我的收获 🎉
经过这段时间的学习,我最大的收获是:
架构思维:
- ✅ 理解了用户系统的完整架构
- ✅ 学会了从全局角度思考问题
- ✅ 明白了各组件的职责划分
安全意识:
- ✅ 密码加密不是可选项,是必选项
- ✅ Token 设计要考虑安全性
- ✅ 并发问题必须重视(原子操作)
工程化思维:
- ✅ 代码组织和命名规范很重要
- ✅ 关注点分离让代码更易维护
- ✅ 安全检查清单帮我避免遗漏
结语 🚀
用户认证系统是我学习后端的第一个完整项目。作为一个前端开发,这次经历让我对后端有了全新的认识。
我最大的感悟:
- 安全第一:密码加密、Token 验证、权限检查,每一步都不能省
- 原子操作:并发问题比想象中复杂,必须在数据库层面保证
- 职责分离:每一层只做自己的事,这样代码才能长期维护
现在,我不仅会调用后端接口,还能理解接口背后的设计逻辑。这种感觉真好。
下一步:进入 AI 的世界,学习大语言模型和 Prompt 工程!
继续加油!💪
- 本文链接:https://fridolph.top/posts/2026-02-05__nest19-user
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。