从零手搓苍穹外卖,Day3 的登录鉴权和分类管理,我踩了几个坑
今天继续聊《苍穹外卖》这个实战项目的第三天进度。按照我自己的节奏,前两天把项目骨架、公共字段自动填充、主菜品分类的新增和分页查询这部分搭完了,Day3 核心任务就是两块:员工端登录 + 动态鉴权(JWT),以及菜品分类的管理闭环(新增、分页、删除、修改)。说实话,REST 接口写多了之后,最枯燥的就是 CRUD 里的重复劳动,但登录和鉴权这一环,只要你做过一次带完整拦截器和令牌校验的流程,后面写啥业务都顺手很多。
这篇文章我会按实际动手顺序来写,涉及到的代码以思路和关键片段为主,不会贴完整项目源码。毕竟每个版本的苍穹外卖课程代码细节有差异,但登录流程、拦截器注册、ThreadLocal 存取当前用户、分类管理的前后端联调这四条主线是所有版本绕不开的。文章末尾我整理了自己实操时踩过的三个坑和排查过程,这部分也一并分享出来。
1. 项目整体设计与思路拆解
1.1 Day3 到底要做哪些事
如果不看课程目录,单从业务闭环来推,Day3 的核心目标就一个:让后台管理端具备“可登录、可鉴权、可管理基础数据”的最小可用状态。拆开看就是三件事:
一是员工端登录接口。前端提交用户名和密码,后端校验通过后给前端返回一个令牌(Token),前端后续请求都带上这个令牌证明“我是谁”。
二是鉴权拦截器。所有需要登录才能访问的接口(比如菜品分类管理接口),都要经过拦截器校验,令牌不合法或过期直接打回 401。这一步本质上是给项目加一道统一的安全闸门。
三是分类管理业务。这属于主菜品分类和套餐分类的通用管理,包括新增分类、分类分页查询、删除分类、修改分类四种操作。分类在苍穹外卖里的角色很关键,它是菜品和套餐的归属维度,没有分类体系,后面的菜品管理没法做。
有同学可能会问,为什么登录鉴权要放在 CRUD 之前?答案很简单:分类管理的四个接口必须依赖当前登录用户信息,比如新增分类时要记录“创建人”,最稳妥的方式就是从登录态里取,而不是前端传一个用户 ID 过来。前端传 ID 是极不安全的,任何人都能伪造请求体,所以必须在后端解析令牌,拿到真正的员工 ID。
1.2 为什么登录鉴权用 JWT,而不是 Session
这是个老生常谈的问题,但落到苍穹外卖这种前后端分离项目里,确实有必然性。传统 Java Web 项目用 Session 做会话管理,靠的是服务端存储 SessionId,客户端存 Cookie,每次请求自动带上。但前后端分离以后,前端可能不在同一个域名下,移动端甚至没有 Cookie 概念,跨域场景下 Session 的处理会很别扭。
JWT(JSON Web Token)的核心优势是服务端无状态,令牌本身就是一个包含用户信息的经过签名的 JSON 字符串。后端只需要校验令牌的签名是否合法、是否过期,就能确认用户身份,不需要查 Session 表。它由三部分组成:Header、Payload、Signature,中间用点号分隔。Payload 里可以存放员工 ID、员工用户名、过期时间等自定义内容。
具体到苍穹外卖的技术栈,用的是 Spring Boot 2.x 系列,配合一个小巧的 JWT 工具类,生成令牌时传入员工 ID 和用户名,设置有效期比如 8 小时。前端拿到令牌后存到 LocalStorage 或者 Pinia 状态里,每次请求在请求头 Authorization 里带上Bearer <token>这样的格式,后端拦截器再按同样的规则解析。
1.3 分层结构和代码组织方式
Day3 的代码组织延续了苍穹外卖项目一贯的规范分包方式。我习惯把代码按 controller、service、mapper 三层来放,每一层职责非常明确。Controller 层只管接收参数、调用业务、封装返回结果;Service 层处理业务逻辑,比如判断用户名是否存在、比对密码、生成令牌等;Mapper 层负责数据库操作,对应 MyBatis 的接口和 XML 映射文件。
新增的 JWT 相关类和拦截器,我放在了独立的包里面,比如common包下放 JwtUtil 工具类、JwtProperties 配置类(读取 application.yml 里 jwt 相关的配置项),config包下放 WebMvcConfiguration 注册拦截器。这样做的目的是把技术组件和业务代码隔离,以后换一种鉴权方式,只需要动这个独立包,业务层完全不用改。多项目实战之后你会发现,这种“技术组件隔离”的习惯能省很多事。
2. 核心细节解析与实操要点
2.1 登录密码的双重校验逻辑
员工登录看似简单,但有一个很关键的细节:数据库里存的密码不是明文,而是 MD5 加密后的密文。创建员工账号时,默认密码一般是 123456,但这个 123456 是经过 MD5 处理后才存进数据库的。登录时,前端传来的密码先做一次 MD5 加密,再和数据库里的密文比对;如果相等,说明密码正确。
为什么不在数据库里存明文?因为数据库一旦泄露,所有账号的密码就全暴露了。MD5 虽然已经不算强加密(有彩虹表风险),但在教学项目里演示“密码不能明文落库”这个思想完全够用。实际生产环境会用 BCrypt 这类加盐哈希算法,但思路是一样的:任何场景下,服务端都不能保存用户可读的密码原文。
比对时有一个逻辑分支很容易被忽略:先查员工表里有没有这个用户名,没有再直接返回“账号不存在”;存在再看密码对不对,不对就返回“密码错误”。这两个错误信息在很多商业项目里会刻意统一成“用户名或密码错误”,防止恶意用户通过提示信息判断哪些账号是注册过的。不过苍穹外卖作为教学项目,刻意保留了区分提示,方便调试。
2.2 JWT 生成与解析的完整细节
JwtUtil 的核心就两个方法:生成令牌和解析令牌。生成令牌时,首先要设置 Header 里的签名算法,一般用 HS256,然后设置 Payload 里的自定义内容,比如员工 ID、用户名,再设置签发时间和过期时间。最后用密钥和签名算法生成 Signature,拼出完整的令牌字符串。
密钥从哪来?一般配置在 application.yml 里,格式类似pro-jwt-secret-key,密钥要足够长足够随机的字符串,不能写死在代码里,否则就是硬编码风险。这里有个容易踩的坑:密钥长度如果太短,某些 JWT 库会直接抛异常;我第一天搭这个项目时就用了个很短的测试密钥,结果启动后一调登录接口就报错,后来把密钥调整到 16 位以上才稳定。
解析令牌时,用 JwtUtil 提供的 parser 方法,传入令牌和密钥,然后从 Claims 里取出员工 ID。需要注意的是,解析时如果令牌过期或签名不合法,会抛出异常,所以调用处一定要用 try-catch 包住,否则拦截器里一个异常抛出去,整个请求链路就断了,返回给前端的是乱七八糟的 500 错误,而不是清晰的 401 未登录提示。
2.3 ThreadLocal 保存当前登录用户的设计思路
登录成功后,拦截器里能拿到员工 ID,但业务层(Service、Mapper)也需要这个 ID,比如新增分类时要记录创建人。这时候可以做个很实用的设计:用 ThreadLocal 保存当前登录员工 ID,业务代码随时取用。
我理解 ThreadLocal 的语义就是“线程私有变量”。一次 HTTP 请求从进入到返回,基本上在一个线程内执行,所以在这个线程里设置一个变量,后续同一线程的任何地方都能拿到。等请求结束,在拦截器的 afterCompletion 方法里调用 ThreadLocal 的 remove 方法清理掉,避免线程池复用导致的“串号”问题。
这个设计我在做苍穹外卖 Day3 时专门封装了一个 BaseContext 工具类,里面就是 ThreadLocal 的 set、get、remove 三个静态方法,专门用来存员工 ID。后面做菜品管理、套餐管理时也会复用这个工具类,做成公共类放 common 包里很合理。
2.4 拦截器注册与放行路径的取舍
Spring Boot 里注册拦截器很简单,实现一个 HandlerInterceptor,然后在 WebMvcConfiguration 里重写 addInterceptors 方法,用 registry.addInterceptor 注册,再用 addPathPatterns 和 excludePathPatterns 指定拦截范围和放行范围。
苍穹外卖里登录接口本身肯定是放行的,否则用户还没登录就被拦截了,那就陷入死循环;员工退出登录操作也该放行,或者更准确地说,退出操作在 Controller 里不做真正的服务端状态销毁,只需要前端清掉令牌即可,所以后端可以直接放行。此外,一些公共资源路径,比如静态资源或测试用接口,也按需放行。
这里有一个值得注意的设计点:拦截器的顺序。如果项目里有多个拦截器,先注册的先执行;苍穹外卖这个阶段通常只有一个鉴权拦截器,所以顺序问题不明显,但如果你后面加日志拦截器或接口耗时统计拦截器,就要考虑先后关系了。我用的时候习惯把耗时统计放在最外层,鉴权放在内部,这样日志能记录到所有请求,包括未登录的请求。
3. 实操过程与核心环节实现
3.1 数据库表结构与初始化数据准备
Day3 用到的表主要是员工表和分类表。员工表是之前就有的,字段包括 id、name(用户名)、password、phone、sex、status、create_time、update_time、create_user、update_user。分类表是我们今天要用的核心表,字段包括 id、type(类型,1 是菜品分类,2 是套餐分类)、name、sort、status、create_time、update_time、create_user、update_user。
初始化数据时,员工表里默认有一条管理员账号,密码存的是 MD5 加密后的值。我在测试登录接口时,可以直接用数据库里的这条记录验证。分类表可以先插入两条测试数据,方便后面分页查询和删除接口的调试。
建表时要注意几个字段的类型和约束。比如type字段用 tinyint 就行,name字段不允许重复,这在后面“新增分类同名校验”逻辑里会用到。数据库的初始化 SQL 在课程资源里一般都有提供,但自己手动敲一遍比直接导入更能加深印象,尤其是字段名和类型,后面写 Mapper 映射时全靠这些细节。
3.2 员工登录接口的实现步骤
员工登录接口的代码实现流程不复杂,但每一步都有讲究。第一步,接收前端传来的EmployeeLoginDTO,里面只有 username 和 password 两个字段。第二步,根据 username 查员工表,没有查到就抛业务异常,提示“账号不存在”。
第三步,比对密码。这里我用自定义的PasswordEncoder工具类对前端传来的密码做 MD5 加密,再和数据库中的密文比对。如果不一致,抛业务异常“密码错误”。第四步,检查员工状态,如果 status 等于 0,说明账号被锁定,要抛“账号已被禁用”异常。这个细节很多人写的时候容易漏,但真实外卖系统的员工离职、账号冻结是再常见不过的场景。
校验全部通过后,第五步就是用 JwtUtil 生成令牌,把员工 ID 和用户名作为 Payload 的内容传进去。最后返回一个EmployeeLoginVO,包含员工基本信息(id、用户名、姓名、电话等)和令牌。前端存好这个令牌,后续请求就能带上了。
3.3 拦截器校验令牌的完整链路
拦截器里我实现的逻辑分成三步:从请求头获取令牌、校验令牌、放行。获取令牌时,先取 Authorization 这个请求头的值,如果为空,直接向响应里写入 401 状态码并返回 false。这里有个小细节:有些前端会把令牌直接放在请求头里(不写 Bearer 前缀),有些会写成Bearer xxx的格式,所以解析时要注意去除前缀,我用一个常量把Bearer写清楚,解析时做字符串截取。
校验令牌的核心方法是 JwtUtil 的 parseJWT,传入令牌和密钥,拿到 Claims 后,从里面取员工 ID,存到 BaseContext 里。如果解析过程抛异常,说明令牌过期或非法,同样返回 401。最后返回 true 放行请求,并在 afterCompletion 方法里调用 BaseContext 的 remove,把 ThreadLocal 清掉。
执行顺序是这样的:Controller 方法执行前,先走 preHandle;方法执行完、视图渲染前,走 postHandle;整个请求处理完后,走 afterCompletion。我们项目里返回 JSON 数据,不需要视图渲染,所以 postHandle 一般不写东西,只要 afterCompletion 做清理就行。
3.4 分类管理四个接口的实操要点
分类管理接口本身是常规 CRUD,但有几个点很考验细节处理。
新增分类时,Controller 接收一个 CategoryDTO,包含类型、名称、排序等字段。Service 层在新增之前要做两件重要的事:一是检查同名分类是否已存在,二是设置公共字段。由于我们已经做了公共字段自动填充(用自定义的 MyBatis 拦截器或 MetaObjectHandler,具体看项目实现),所以 create_time、update_time、create_user、update_user 这四个字段不用手动 set,数据库自动填充。但 status 字段要注意,新增时默认是禁用状态(0),启不禁用要等管理员手动操作。这个“新增默认禁用”的逻辑在真实项目里很常见,不能随手设成启用。
分页查询分类时,前端传页码、每页大小、类型(可选)和名称(可选)四个参数。后端查到数据后,返回一个 PageResult 对象,里面包含总记录数和当前页数据列表。查询条件是用动态 SQL 拼的,MyBatis 的<if>标签在 where 子句里判断参数是否为空。注意如果前端传了名称做模糊查询,SQL 要用LIKE CONCAT('%', #{name}, '%')拼接,防止 SQL 注入。
删除分类时,Service 层要先判断这个分类下是否还有菜品或套餐,有的话不能删除。因为分类和菜品之间存在外键关系,如果强行删掉分类,分类下的菜品会变成“无家可归”的数据。Day3 这个阶段还没有菜品表,所以很多课程版本里没有做这个校验就直接删,但真实项目一定要做。我到 Day5 做菜品管理时会回头补上这个关联校验。
修改分类时,主要是修改名称、排序、状态。更新操作要和公共字段自动填充配合好,update_time 和 update_user 这两个字段必须更新为当前时间和当前用户。分类状态修改是个很常用的操作:把 status 改成 1 表示启用,改成 0 表示停用。前端界面的停用/启用按钮就是调这个接口。
4. 常见问题与排查技巧实录
4.1 登录接口报 401,但明明输入的是正确密码
这个是我 Day3 遇到频率最高的问题。排查步骤我建议按这条线走:先看前端是否真的把令牌带上了。按 F12 打开浏览器开发者工具,切到 Network 面板,选中一个需要鉴权的请求,看请求头里有没有 Authorization 字段。没有就说明前端逻辑有问题,可能是一开始就没存好令牌,或者存的位置和后端预期不一致。
再看后端拦截器有没有放行登录接口。登录接口是唯一不需要令牌的接口,如果拦截器配置没把登录路径 exclude 掉,那么前端一调登录接口就被拦下,自然返回 401。登录接口返回 401 是最典型的拦截范围配置错误,检查 excludePathPatterns 里的路径和前端实际请求路径是否完全一致,尤其注意有没有多一个斜杠或者大小写不同。
最后再看令牌解析有没有出问题。如果令牌是有的,但解析失败,检查前端存储的令牌是不是完整的(JWT 由三段组成,中间用点号分隔),有时候字符串被截断或者被 URL 编码导致乱码,也会导致解析异常。
4.2 分页查询查不到数据或查的数据不对
分页查询做完了,但列表一直空,这个问题也很常见。先检查数据库里是否真的插入了分类数据。接着检查分页参数:前端传的页码从 0 开始还是从 1 开始?很多前端组件习惯页码从 0 开始,但后端 PageHelper 默认从 1 开始,两边的语义不一致,就会导致第一页正常、第二页错位或者查不到数据。
再看动态 SQL 的拼接条件。我用 MyBatis 的<where>标签自动去除多余的 AND,但有时候<if>的判断条件写错,比如参数名没对上,MyBatis 里参数名都不对的话,条件会静默失效,导致 where 子句是空的。这种情况最坑,因为不报错,只返回全表数据,看起来像“分页失效了”。
另外注意分页查询时 PageHelper 的 startPage 方法必须紧接着调用在 Mapper 的查询方法之前,中间不能有别的数据库操作。如果 startPage 和查询之间插入了一个别的不相关的查询,PageHelper 会把分页参数应用到错误的 SQL 上,导致结果错乱。这个问题排查起来非常隐蔽。
4.3 新增分类总提示“分类名称已存在”,但数据库里根本没有
这是我自己踩过的一个很典型的坑。因为检查重名的 SQL 写的是模糊查询,而不是精确查询。比如我用LIKE CONCAT('%', #{name}, '%')做重名校验,那么新增一个叫“热销”的分类时,只要数据库里有个“热销榜”分类,就会被判定为重复,因为“热销”是“热销榜”的子串。
解决方式很简单:重名校验应该用精确匹配,即WHERE name = #{name},而不是 LIKE 模糊查询。这个教训的背后逻辑是,查询用途决定 SQL 写法。列表页的搜索框是给用户做模糊筛选用的,应该用 LIKE;而重名校验是唯一性约束业务,必须用精确匹配。两种情况混用,是新手写 SQL 时最容易犯的错。
我在修正这个 bug 后,又在数据库层面给 name 字段加了一个唯一索引,双保险。但要注意的是,加了唯一索引后,插入重复数据会抛 DuplicateKeyException,Spring Boot 默认会返回 500,所以还得在 Service 层先做一次预检查,或者捕获异常转换成业务提示。
4.4 修改分类后,更新时间和更新人没有变化
这个问题的根源通常是公共字段自动填充没生效。原因之一:实体类里的 update_time 字段没有加对应的填充注解,或者 MyBatis 拦截器里没配置这个字段的填充逻辑。原因之二:数据库字段名和实体属性名没对上,比如数据库是下划线update_time,实体类是驼峰updateTime,没有开启 map-underscore-to-camel-case 配置的话,MyBatis 就映射不上。
还有一种情况:修改操作走了 XML 里的 update 语句,但这条 SQL 是手写的UPDATE category SET name = #{name} WHERE id = #{id},完全没有写update_time = #{updateTime},而公共字段自动填充只能自动帮你 set 实体对象的属性值,它不会帮你改手写的 SQL。这时候要么在 XML 里手动加上这些字段,要么改成用 MyBatis-Plus 的更新方法(如果项目里用了的话)。
我个人的习惯是:手写 SQL 时,公共字段尽量显式写进 SET 子句,因为自动填充虽然省事,但出了问题时排查成本高。Day3 阶段把公共字段的填充逻辑摸透了,后面做菜品、套餐管理会非常顺。
5. 一点经验总结
Day3 做完之后,我对这个项目的感受从“熟悉框架 API”切换到了“熟悉业务状态流转”。登录鉴权、分类管理这些功能虽然不算难,但它们的组合方式代表了绝大多数后台管理系统的核心形态:先解决谁在用的问题,再解决用的人要管理什么数据的问题。
我个人的体会是,Day3 的代码量并不大,但它是整个项目中第一个真正把“前端请求-后端拦截-业务处理-权限标记”这条链路走通的关卡。后面做菜品管理、套餐管理,本质上都是在已经铺好的这条链路上追加业务,区别只是表的数量和关联关系变得更复杂。所以 Day3 走稳一点,后面的每个 Day 都会轻松很多。
最后再分享一个小技巧:调试拦截器时,不要只靠 Postman 手工拼令牌。可以在登录接口返回后,用 JavaScript 把令牌自动提取到 Postman 的全局变量里,这样后面所有需要鉴权的接口都能自动带上请求头,调试效率能提升一大截。我在做 Day3 时就是这么配置的,已经用了好几年,从来没觉得过期。