苍穹外卖这个名字,在Java后端圈子里出镜率高到什么程度?面试聊项目,十个候选人里有八个简历上写着类似这样的一套管理端;搜学习路线,弹幕也总飘过一句“Java也是瓦苍穹外卖”。玩笑归玩笑,把这个项目拆开看,Day01把环境、数据库、前后端骨架搭好之后,Day02就是真正的业务实战:员工登录、员工信息管理,一个后台管理系统最常见的认证和增删改查,全在这一天过一遍。
这篇就按我自己写代码的顺序,把Day02涉及的核心接口、关键代码、容易翻车的点完整讲清楚。正在做苍穹外卖的可以参考,已经写过基础CRUD的,可以重点看看JWT拦截器、PageHelper分页和ThreadLocal传值这几个隐藏细节。说句实话,能不能把Day02写得顺畅,基本就能看出你对Spring Boot那套Controller-Service-Mapper链路有没有真正形成手感。
1. 先理清Day02要完成的业务闭环与架构思路
1.1 员工端后台的“首个完整业务闭环”
Day02不是零散地写几个接口,而是围绕“员工账号”把一套后台管理的最小闭环补全。说白了就两件事:让管理员能登录进去,让管理员能管理所有员工账号。登录本身解决身份认证问题,员工管理解决的是登录之后可以对系统内的账号做增删改查。
这个闭环里最值得关注的是安全问题。登录不是随便查一下数据库就返回成功,而是要用一种无状态的方式把登录状态交给前端;员工管理也不是无脑写SQL,而是要区分不同角色能做什么操作、同一个账号能不能被禁用、密码该怎么存。很多同学到了Day03之后才发现前面的模块写得太粗糙,回头又要改,根源往往是Day02只把接口跑通,没把每一层设计的“为什么”想明白。
我个人的习惯是动手前先把要做的接口列出来。Day02的接口很典型,可以按功能分成认证和员工管理两组,后面写代码的时候就照着清单逐个击破:
| 功能描述 | 请求方式 | 路径 | 是否依赖登录态 |
|---|---|---|---|
| 员工登录 | POST | /admin/employee/login | 否 |
| 新增员工 | POST | /admin/employee | 是 |
| 员工分页查询 | GET | /admin/employee/page | 是 |
| 启用或禁用员工 | POST | /admin/employee/status/ | 是 |
| 根据ID查询员工信息 | GET | /admin/employee/ | 是 |
| 编辑员工信息 | PUT | /admin/employee | 是 |
| 修改密码 | PUT | /admin/employee/editPassword | 是 |
先看到这张表,再去看代码,就不会有“学完登录突然跳到增删改查”的断裂感。登录是给后面所有接口做铺垫,员工管理才是Day02真正要练的实际业务能力。
1.2 开发前需要先看一遍employee表结构
写员工模块的人都绕不开一张表:employee。不同版本的教材字段会有细微差别,但核心字段基本一致,如果你手里有建表脚本,对照着看会清晰很多。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| name | varchar(32) | 员工姓名 |
| username | varchar(32) | 登录用户名,通常会加唯一索引 |
| password | varchar(64) | 登录密码,密文存储 |
| phone | varchar(11) | 手机号 |
| sex | varchar(2) | 性别,值是“男”或“女” |
| id_number | varchar(18) | 身份证号 |
| status | int | 账号状态,1启用,0禁用 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
| create_user | bigint | 创建人ID |
| update_user | bigint | 更新人ID |
注意password字段至少要到64位,因为项目里用的是32位MD5密文,如果你的表结构是旧的32位也能存,但如果你后面想换加盐哈希,长度就不够了。status默认值建议直接写在建表语句里,避免每次插入都忘。create_user、update_user这两个字段在Day02会频繁出现,千万别在做完登录后就把它们丢了,这是后面练习公共字段自动填充的重要载体。
1.3 一天代码量怎么拆,才不会写着写着乱掉
有同学一天能把这七个接口全写完,有同学写了一个新增就卡住。区别不在地代码速度快,而在于有没有按照依赖关系去推进。我推荐的拆解顺序是:统一响应类Result → 登录接口 → JWT工具和拦截器 → 用Postman验证登录态 → 员工新增 → 分页查询 → 启用禁用和编辑 → 修改密码。
为什么要先做登录?因为登录是测试后面所有接口的钥匙。没有登录态,员工管理的接口都会被拦截器挡住,等于你先搭好了一套安全门禁,后面每开发一个接口都能立刻验证“能不能进去”。如果你顺序反过来,先把增删改查写完再补登录,就会发现之前用Postman测试时直连接口的方法全都不好使了,还得回头改一堆前端联调代码。
另外一个容易忽略的点是:新增员工和编辑员工都需要记录操作人和操作时间。这些字段如果手动逐个Set,代码会非常啰嗦。我建议Day02先按手动写法把接口调通,然后再抽个时间用AOP把它优化成公共字段自动填充,这样这个模块才算是真正做透了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 员工登录模块:从JWT到拦截器的完整链路
2.1 登录接口的流程设计
员工登录的接口看起来只是一个“接收用户名密码,返回token”的过程,但它内部每一步都有明确目的。标准的苍穹外卖登录Service层流程大概是下面几步:先根据用户名查数据库,查不到就抛“账号不存在”;能查到就比对MD5密码,不一致就抛“密码错误”;密码一致后再判断status状态,如果是0就说明账号被禁用,抛“账号已被锁定”;全部通过之后,用员工ID和用户名生成JWT并返回。
可能有人觉得“账号不存在”和“密码错误”为什么要分开提示,直接统一一个“用户名或密码错误”不是更安全吗?从防暴力破解的角度看,统一提示更稳妥。但教学项目里分开返回是为了让调试更直观,生产环境如果是我自己写,会更倾向于把这两个异常合并成同一个错误信息。
核心代码结构大致是:
java复制public Employee login(EmployeeLoginDTO employeeLoginDTO) {
String username = employeeLoginDTO.getUsername();
String password = employeeLoginDTO.getPassword();
Employee employee = employeeMapper.getByUsername(username);
if (employee == null) {
throw new PasswordErrorException("账号不存在");
}
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(employee.getPassword())) {
throw new PasswordErrorException("密码错误");
}
if (employee.getStatus() == StatusConstant.DISABLE) {
throw new AccountLockedException("账号已被锁定");
}
return employee;
}
Controller层拿到Employee对象后,取出ID生成JWT,再拼装成登录VO返回给前端。这里面有个细节:Entity实体不要直接返回给前端,尤其像Employee这种包含了密码、身份证号的表实体,一旦序列化出去就是风险。登录结果应该单独建一个EmployeeLoginVO,里面只放id、用户名、姓名和token。
2.2 JWT生成与校验的代码结构
JWT说透了就是一个自包含的令牌。服务端生成一段带签名的字符串,客户端在后续请求里带着它,服务端验签通过就认为是合法用户,不需要像Session那样在服务器内存里保存一份会话记录。这对前后端分离的项目尤其友好,因为登录状态可以随着请求头传递,后端横向扩展时也不需要额外同步Session。
实现上,苍穹外卖一般用的是jjwt库,不同版本的API有差异。如果你手里是老项目,可能看到的是new JwtBuilder()这种写法;新版jjwt则推荐用Jwts.builder()链式调用。我以最常用的方式演示:
java复制public String createJWT(Map<String, Object> claims, Long ttlMillis) {
return Jwts.builder()
.setClaims(claims)
.setExpiration(new Date(System.currentTimeMillis() + ttlMillis))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
这里的secretKey属于敏感配置,一定要放在配置文件里,不要写死在代码中。ttl时长要结合业务来定,苍穹外卖后台管理端的token有效期一般是两小时,也就是7200000毫秒。有效期太短,管理员可能正在填写表单就被踢下线;有效期太长,万一密码泄露,别人能拿着token薅很久。用配置类读取之后,每次修改都不需要重新编译。
解析端的写法看着更简单,但关键是要处理异常。很多人的登录态“莫名其妙失效”,其实就是因为令牌过期或者被篡改后,底层抛了异常,而拦截器没接住。我习惯在解析方法里把JwtException和IllegalArgumentException都捕获掉,统一抛出一个业务异常,让全局异常处理器返回401,而不是让异常一路冒到Spring默认错误页。
2.3 拦截器校验Token并传递登录人的实现
真正考验代码功底的是拦截器。JWT生成不难,难的是怎么把“每个受保护接口都校验Token”这件事做干净。很多人会想到在每个Controller方法里手动解析Token,但那样会有大量重复代码,而且很容易漏掉某个接口。标准做法是定义一个HandlerInterceptor,在preHandle里统一处理。
拦截器的preHandle要做三件事:从请求头取出前端传过来的Token,调用JWT工具类解析;解析成功就把当前登录人的ID存到ThreadLocal上下文里;解析失败就设置401响应并返回false,请求直接终止。
为了精简且后续好维护,JWT存哪个请求头也要提前定好。很多项目习惯在登录时返回一个token字符串,前端用Authorization或者自定义的token字段携带。苍穹外卖里往往会定义一个前缀,比如token名称叫token,放在请求头里。拦截器代码里获取请求头名称时一定要和配置文件一致,否则会出现“前端带了Token,后端却说没收到”的诡异问题。
ThreadLocal是另一个容易被忽略的知识点。它的作用可以简单理解成给每个线程准备了一个独立的小储物柜。一次HTTP请求在Tomcat里基本由同一个线程处理,所以Controller、Service、Mapper任意一层想拿当前登录人ID,都可以从ThreadLocal里取,不需要把ID一层层作为参数传递。
我建议单独写一个BaseContext工具类来封装ThreadLocal的读写:
java复制public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
为什么登录人的ID要这样做?说白了就是给新增员工和修改员工时使用的create_user、update_user字段提供数据来源。新增员工接口的Service层要拿当前登录人ID,如果用参数传递,从Controller一路传到Service,接口签名会越来越难维护;用ThreadLocal,在哪一层都能取到。
不过ThreadLocal有个使用禁忌:当前请求处理完必须清理,否则线程池里的线程被复用后,下一个请求可能读到上一个登录人的ID,造成数据错乱。所以拦截器里做完afterCompletion时,要调用BaseContext.removeCurrentId()。这一点很多教程没强调,但它是一条真实生产事故线。
2.4 登录环节常被忽略的边界情况
写登录时,有几个细节经常被新手忽略,但它们恰恰是面试官最喜欢问的。
第一个是用户名的唯一性。登录查询是用username查的,如果表结构里没有为username加唯一索引,数据库层面就允许重复用户名存在,那么getByUsername就可能返回多条记录,Service层如果直接用Employee接收就会出现异常。建表时就应该对username加唯一约束。
第二个是密码的存储格式。很多项目演示直接存明文,那是因为本地测试无所谓。苍穹外卖里用了MD5做单向摘要,默认密码一般是123456,存入数据库前先做一次MD5。MD5只能算“防君子不防小人”,因为彩虹表可以反查,真实项目会加盐或使用BCrypt这类算法,Day02阶段先理解摘要和明文的区别即可。
第三个是登录时要不要判断status。账号被管理员禁用后,理论上是不能登录的,所以登录Service里要判断status是否为禁用状态。这个判断放的位置是登录校验之后、生成Token之前。有的人只在员工管理接口里判断,登录接口没判断,结果被禁用的账号依然能登录进去,只是在操作时被打回来,体验很怪。我的建议是两边都做判断,登录时拦截能给出更友好的提示,操作时拦截是为了防止在极短时间内状态发生变化后依旧持有合法Token。
3. 员工管理的四个CRUD接口逐个攻破
3.1 新增员工:DTO收参数、实体入库、默认密码
到了新增员工,就要开始养成“请求参数不入Entity”的习惯。新增员工时,前端传过来的是一组JSON字段,如果直接用Employee接收,等于把实体类的所有属性都暴露给了前端,别人理论上可以传id、password、status这些不该传的字段。所以Controller层应该定义EmployeeDTO,只包含需要接收的字段:username、name、phone、sex、idNumber。
Service层拿到DTO之后,再手动把它转成Employee实体,并补全业务默认值:
java复制Employee employee = new Employee();
BeanUtils.copyProperties(employeeDTO, employee);
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
employee.setStatus(StatusConstant.ENABLE);
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
employeeMapper.insert(employee);
这套逻辑里最值得玩味的是默认密码。新员工由管理员创建时没有输入密码,系统会给他一个初始密码,苍穹外卖里的惯例是123456。后续员工拿到账号后应该可以自己去修改密码,这样默认密码就从“公开的秘密”变成了一个临时凭证。因此修改密码接口也是Day02不能漏掉的一个小功能。
关于密码复杂度和重复提交问题,这里不多展开,但你们可以给自己加需求。比如新员工首次登录强制改密,或者用唯一索引保证同一个username不能被插两次。加需求不是为了炫技,而是让代码更贴近真实业务,简历上也有的写。
新增员工时最容易出现的异常是用户名重复。数据库层有唯一索引兜底,第二次插入同一个用户名时,MySQL会抛SQLIntegrityConstraintViolationException。如果不处理,前端用户会看到一个又长又难懂的英文异常;好的做法是用全局异常处理器统一捕获,并判断异常信息里是否包含“Duplicate entry”和“username”相关的关键词,若命中就返回“用户名已存在”。这些细节会在后面问题排查章节继续展开。
3.2 分页查询:PageHelper和动态SQL
员工分页查询是Day02里最有含金量的一部分,因为它同时考了三件事:分页插件原理、动态SQL拼接、返回结果封装。
接口参数通常是page、pageSize和name三个字段,name是可选的模糊查询条件。如果让每个人自己用limit去算offset,代码很容易乱;用PageHelper可以在不改SQL的前提下,自动把查询改装成带limit的分页语句。
使用PageHelper最常见的手写姿势是这样的:
java复制public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) {
PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize());
Page<Employee> page = (Page<Employee>) employeeMapper.pageQuery(employeePageQueryDTO.getName());
long total = page.getTotal();
List<Employee> records = page.getResult();
return new PageResult(total, records);
}
这段代码的坑点在于startPage和查询之间不能夹其他SQL语句。PageHelper底层用了ThreadLocal保存分页参数,它的设计原则是“谁调用startPage,接下来的第一条查询就被分页”。如果你在startPage和mapper查询之间做了日志查询、调了其他Mapper,分页参数就可能落到错误的查询上,导致明明只想分页查询员工,结果total统计却变成了查询别的表的结果。
更稳妥的写法是用PageHelper提供的Lambda方式:
java复制Page<Employee> page = PageHelper.startPage(dto.getPage(), dto.getPageSize())
.doSelectPage(() -> employeeMapper.pageQuery(dto.getName()));
这种方式让分页和查询绑定在一起,从代码结构上消灭了“分页参数落到其他查询”的隐患。
分页查询的Mapper还有一个难点:姓名模糊查询。在XML里要用动态SQL:
xml复制<select id="pageQuery" resultType="com.sky.entity.Employee">
select id, username, name, phone, sex, id_number, status, create_time, update_time, create_user, update_user
from employee
<where>
<if test="name != null and name.trim() != ''">
and name like concat('%', #{name}, '%')
</if>
</where>
order by create_time desc
</select>
这里用
另外我强烈建议分页查询返回的记录不要直接包含身份证号明文。课程项目里为了方便,经常直接把Employee列表返回前端,真实项目中这类敏感字段要么脱敏,要么用VO只返回需要的字段。如果你想显得专业一点,可以在查询后用VO做一层转换,把idNumber中间几位用*替换掉。
3.3 启用禁用:一个状态字段也需要写Clear逻辑
启用禁用账号在接口上长得很简单,前端请求一个路径参数status,再传一个员工id,后端执行update语句把status改掉就行。但越简单的需求越容易在细节上翻车。
Controller常见写法是把status作为路径变量:
java复制@PostMapping("/status/{status}")
public Result<String> startOrStop(@PathVariable Integer status, Long id) {
employeeService.startOrStop(status, id);
return Result.success();
}
这里的id是从哪来的?有的是查询参数,有的是放在请求体里,不同项目风格不一样。苍穹外卖里的设计更接近“status走路径、id走请求参数或表单”的混搭风格。你自己联调时要注意看前端到底把id放在什么地方,如果前端放在请求体JSON里,Controller还只写一个Long id接收,Spring会解析不到,最后接口收到id为null,执行出来的效果就是整表状态被改。
Service层核心逻辑其实就是一个带条件的更新:
java复制public void startOrStop(Integer status, Long id) {
Employee employee = Employee.builder()
.id(id)
.status(status)
.build();
employeeMapper.update(employee);
}
这段代码能不能成立,取决于Mapper里的update方法是否是动态更新。如果写的是无条件“update employee set status = ? where id = ?”,那就没问题;但如果复用了编辑员工的update方法,XML里只更新非空字段,那么传一个只有id和status的对象,最终只会改到status这一个字段。动态SQL在这里反而成了优势,能把启用禁用和编辑员工共用一个更新方法。
一个容易被忽略的业务规则是:不能让自己把自己禁用了。假如当前登录的管理员ID是1,他发起请求把id=1的员工状态改成0,那么更新一完成,他手上的所有请求都会因为状态被禁用而失效,前端直接“卡死”在页面上。我后来自己在Service层加了一条判断:
java复制if (id.equals(BaseContext.getCurrentId())) {
throw new BaseException("不能修改当前登录账号状态");
}
这种保护不在原始需求里,纯属实际使用后补的加固,但它能避免一场让测试同事抓狂的“生产事故”。
3.4 编辑回显、提交和修改密码是三个不同接口
编辑员工功能通常拆成两个接口:先根据id查询员工原始信息回显到表单,再提交修改后的内容。容易忽略的是,回显接口返回的数据和提交接口接收的数据不完全相等。回显时可能需要给表单展示当前姓名、手机、性别、身份证号,但绝不能把密码暴露给前端;编辑提交时,前端也不应该传password字段。
回显一般再写一个getById方法,查询结果转成EmployeeVO:
java复制public EmployeeVO getById(Long id) {
Employee employee = employeeMapper.getById(id);
EmployeeVO employeeVO = new EmployeeVO();
BeanUtils.copyProperties(employee, employeeVO);
return employeeVO;
}
编辑提交的关键点在于username的更新策略。员工用户名通常是登录凭证,如果允许管理员在编辑页随意改username,就会出现“这个用户名已经被占用”的冲突,还可能造成账号无法登录。实际上,编辑员工更多是修改姓名、手机号、性别、身份证号这些基本资料,用户名和密码都不应该走这个接口。
Mapper里的动态SQL可以这样设计:
xml复制<update id="update" parameterType="com.sky.entity.Employee">
update employee
<set>
<if test="name != null and name != ''">name = #{name},</if>
<if test="phone != null">phone = #{phone},</if>
<if test="sex != null">sex = #{sex},</if>
<if test="idNumber != null">id_number = #{idNumber},</if>
<if test="status != null">status = #{status},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateUser != null">update_user = #{updateUser},</if>
</set>
where id = #{id}
</update>
别忘了在set里排除username和password这两个字段。这既是安全需要,也是业务需要。如果直接把前端传的Employee对象整进去,一旦有人构造请求把username改了,会绕过正常的账号维护流程。
修改密码和编辑员工又是两码事。修改密码需要前端传旧密码和新密码,如果再加一个确认密码字段,在Service层要判断新密码和确认密码是否一致。很多坑就出在“确认密码不一致”这种校验上,你不做判断,用户输错两次自己还不知道,等于密码被悄悄改成了自己不想要的字符串。
修改密码Service的通用流程是:从ThreadLocal里拿当前登录员工ID,查询出数据库中的密码密文;把前端传来的旧密码做MD5,和数据库里的密文比较;一致才把新密码做MD5后更新。旧密码校验不能省,别的管理员也能用这个接口改自己密码,必须确认他确实知道旧密码。
3.5 用AutoFill把重复的“创建人、创建时间”收口
在做新增和编辑的时候,应该已经感觉到手动设置createTime、updateTime、createUser、updateUser这4个字段非常重复。每次新增要写两遍时间、两遍操作人,编辑也要写两遍。这种重复代码是用AOP消除的最佳场景,也是苍穹外卖里很经典的公共字段自动填充练习。
思路是先定义一个注解:
java复制public @interface AutoFill {
OperationType value();
}
执行操作时值要么是INSERT,要么是UPDATE。在需要自动填充的Mapper方法上标注:
java复制@AutoFill(value = OperationType.INSERT)
void insert(Employee employee);
@AutoFill(value = OperationType.UPDATE)
void update(Employee employee);
然后写一个AOP切面,拦截所有标了该注解的Mapper方法。如果是INSERT,就反射调用实体的setCreateTime、setUpdateTime、setCreateUser、setUpdateUser方法;如果是UPDATE,就只调用setUpdateTime和setUpdateUser。获取当前登录用户依然从BaseContext里取。
这个方案的好处是把“谁在什么时候操作了这条数据”这一横切逻辑从业务代码里剥离出来。以后不管新增员工还是新增菜品、分类,只要Mapper方法打上注解,就不需要再手动写那几行赋值了。但要留意,切面方法里用反射拿实体时,Mapper方法参数多个且第一个不一定是实体的情况要把JoincPoint的参数列表遍历一遍,找到Employee类型的对象再处理。
AOP这块如果一时消化不掉也正常,Day02可以先用笨办法写,等后面做到菜品管理、分类管理时再回头看它,会更能体会“一次封装多处复用”的收益。
4. 一眼识别Day02最容易踩的五个坑
4.1 接口总是401,问题可能在拦截器放行规则
项目刚启动时,很多人会先测登录接口,结果发现登录本身都返回401。如果你确定登录逻辑没错,最常见的元凶是拦截器配置里把登录请求也拦截了。注册拦截器的配置类里要显式放行登录路径:
java复制registry.addInterceptor(jwtTokenAdminInterceptor)
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/employee/login");
这里有个很容易被忽略的规律:URL必须与前端请求完全一致才会被匹配。有人Controller里的路径明明是/admin/employee/login,拦截器放行时却写成了/admin/employee/login/,多一个斜杠都不行。Controller的大小写、请求方式也要核对,拦截器只管路径匹配,和HTTP方法无关。
如果你改完放行规则还是401,可以在preHandle里加一行日志打印请求URI,确认实际进来的是哪个路径。眼看到的问题往往比猜要快得多。
4.2 分页查不到数据或total异常
分页查询翻车率极高,但原因其实就那么几类。第一类是PageHelper的startPage和真正查询之间夹了别的SQL语句,导致分页参数作用到了另一条查询上。第二类是Mapper方法本身在分页查询后又执行了别的SQL,PageHelper对第一条select拦截后,第二个查询又可能被影响一次。第三类是返回结果没有强转成Page类型,导致拿不到total。
排查方法很简单:打开MyBatis的SQL日志,看控制台实际执行的SQL是否带了limit。如果带了limit,说明分页生效;如果没带,问题出在PageHelper的链路顺序上。如果带limit但total是0,大概率是返回类型不对,比如你用List接收而不是Page接收,或者把Page对象又封装了一层变成list返回了。
4.3 SQL唯一索引异常处理得不到预期结果
新增员工时,如果用户输入的username在数据库里已经存在,MySQL会抛SQLIntegrityConstraintViolationException。全局异常处理器判断异常信息字符串时,要特别注意数据库版本对错误信息的描述差异。本地用MySQL 5.7和线上用MySQL 8.0,报错信息可能不完全一样,判断关键词别只看“Duplicate entry”就完事,最好把异常信息里的索引名也打出来,确认它确实命中的是员工表用户名索引。
另外,全局异常处理器在捕获异常后,不要把原始异常直接堆给前端。这对用户不友好,也等于把表结构信息泄露了出去。标准做法是记录完整异常日志,只返回友好的业务提示。
4.4 时间字段显示成数组或少了8小时
实体里用LocalDateTime接收日期,如果前端页面上显示的时间变成了“[2024,2,27,14,30,0]”或者查出来永远比北京时间少8小时,根因一般是JSON序列化配置和数据库时区。Spring Boot对LocalDateTime的默认序列化需要依赖Jackson的JavaTimeModule,如果没有配置格式,默认输出的不是“2024-02-27 14:30:00”这种可读格式。
解决方案是在application.yml里统一配置:
yaml复制spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
如果类属性需要单独的格式,就用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。时区少8小时的问题除了检查jackson的time-zone,还要看JDBC连接串是否带了serverTimezone=Asia/Shanghai。这个坑属于环境类问题,不报错但显示不对,往往要排查很久。
4.5 BaseContext里的登录人ID取出来是null或串号
ThreadLocal取不到ID,最常见的原因是这个请求根本没经过拦截器。比如你在测试时直接调Service方法,自然没有JWT解析环节,BaseContext里就没有值。另一种情况是没有在afterCompletion里remove,导致线程池复用线程之后,下一个请求拿到的还是上一个请求的登录人ID,这叫串号。症状是新增员工后,create_user字段一会儿是1、一会儿是5,看日志还查不出规律。
解决起来不复杂:在自定义拦截器里实现afterCompletion方法,调用BaseContext.removeCurrentId()。这条代码不去写很容易埋坑。我见过一个项目上线大半年后,管理员A新增的数据莫名其妙显示创建人是管理员B,最后定位就是ThreadLocal没清理。Day02能尽早养成清理习惯,后面写微信端登录也会少很多麻烦。
5. 把Day02代码变成简历亮点的个人建议
Day02作为项目里第一个业务模块,代码本身不复杂,但它是你梳理Spring Boot后端开发习惯的最佳试炼场。我见过很多人做苍穹外卖,两天就把后台管理端“刷”完了,问他ThreadLocal解决什么问题、JWT为什么不用Session、页面查询为什么用PageHelper,一个都答不上来。这样刷完,项目经验对面试几乎没有加成。
如果你时间宽裕,建议Day02结束后自己重新打开一个空项目,不参考任何源码,把这七个接口再写一遍。写的时候重点体会三件事:第一,参数接收如何做到不过度信任前端;第二,异常处理如何做到既安全又友好;第三,如何在保证功能的前提下减少重复代码。把这三件事想清楚,比多写十个接口都有用。
我个人在带新人或者自己复盘时,还习惯把员工模块当成一个“代码体检报告”。想测试自己对Spring Boot的掌握程度,就看看能不能把登录、拦截器、CRUD、分页、AOP自动填充这些点在一个小时内通顺地讲出来。如果讲的时候磕磕绊绊,说明哪个点还没吃透。
这一天最容易被低估,但它是后面分类管理、菜品管理、套餐管理所有模块的地基。苍穹外卖的价值不在于项目光环,而在于它给了你一条低门槛的路径,把后端日常开发里高频出现的技术和设计过了一遍。好好打磨Day02的员工管理,它不只是第一个模块,更是你技术手感成型的第一块压舱石。
