刚把苍穹外卖的登录认证和JWT那套流程跑通,紧跟着就进入Day02的员工管理模块。这一天的内容信息量明显上来了,不再是单纯的"把接口写出来",而是开始真正接触企业级接口开发里的分层设计、对象封装、公共字段填充这些概念。如果你也是边做边学的状态,我建议你不要急着往后赶进度,Day02里面有几个点是后面所有模块都要复用的,比如统一返回结果、分页查询、公共字段自动填充,值不值得停下来搞透,看这篇文章就知道。
我按自己实际走的顺序来复盘,先说是怎么定位这一天的主线任务,再逐个接口拆解实现思路和代码细节,最后把容易踩的坑单独拎出来讲清楚。
1. Day01遗留问题复盘与Day02主线的确立
1.1 登录链路里的两个关键遗留点
开始写员工管理之前,我先把Day01的代码重新翻了翻。登录模块看似已经跑通,其实有两个点值得在动手前想明白,否则Day02越往后写越容易绕进去。
第一个是JWT令牌的校验时机。Day01只在拦截器里卡了请求头中的token,校验通过就放行。但这时候有个明显的问题:登录拦截器只管"你有没有带token",不管"你是谁"。后续的员工操作接口,比如新增员工、修改员工,都需要知道当前操作人是谁,才能给create_user、update_user字段填值。这个需求在Day01还没解决,Day02的公共字段填充正好补上这块能力。
第二个是统一返回结果Result<T>的结构。说实话,刚开始我总觉得这个包装类有点多余,直接返回对象不好吗?直到写分页查询才体会到,前端需要的是"数据(data)"和"状态提示(msg)"分离的结构,分页数据还要额外带上total字段。如果没有this.result统一封装的规范,每个人写一套响应结构,前端对接起来就是灾难。
1.2 员工管理模块的四个核心接口
Day02真正要我做的事,是把后台的员工管理功能完整实现出来。我在需求文档里梳理了一下,跟前台用户侧逻辑不一样,后台员工管理主要包含四个操作:
- 分页查询员工列表,支持按员工姓名模糊筛选
- 新增员工,参数要校验,密码要加密存储
- 按id查询员工信息用于回显,再配合修改接口
- 修改员工状态,也就是启用/禁用账号
这四个接口覆盖了后端开发里最常见的几种操作形态:条件查询、新增写入、主键查询加更新、简单字段更新。跟Day01的单一登录接口相比,Day02会更全面地展示一个业务模块从Controller到Mapper的完整落法。
1.3 为什么先看数据库设计
我个人的习惯是,写接口前先看表结构。打开employee表,一眼就能看出设计意图。
sql复制CREATE TABLE employee (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
name VARCHAR(32) NOT NULL COMMENT '姓名',
username VARCHAR(32) NOT NULL COMMENT '用户名',
password VARCHAR(64) DEFAULT '123456' COMMENT '密码',
phone VARCHAR(16) COMMENT '手机号',
sex VARCHAR(2) COMMENT '性别',
id_number CHAR(18) COMMENT '身份证号',
status INT DEFAULT 1 COMMENT '状态 0禁用 1启用',
create_time DATETIME COMMENT '创建时间',
update_time DATETIME COMMENT '更新时间',
create_user BIGINT COMMENT '创建人id',
update_user BIGINT COMMENT '修改人id',
UNIQUE KEY idx_username (username)
) COMMENT '员工信息表';
这张表里有两个信息很关键。一是username有唯一索引,新增员工时如果没做重名校验,数据库就会直接抛DuplicateKeyException,这点后面要重点处理。二是status字段用0和1表示禁用和启用,实际业务里前端传的是数字,我们后端直接update这个字段就行,不用做复杂判断。
不过有个有意思的细节,密码字段默认值是'123456',也就是说新增员工时即使我没传密码,DB也会兜底。但按照团队规范,新增逻辑里还是应该显式对密码做MD5加密后写入,而不是依赖数据库默认值,这样既统一又便于后续密码策略调整。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 员工分页查询接口:Controller、Service、Mapper三层联动
2.1 分页插件选型:为什么用PageHelper而不是手写LIMIT
员工列表这种东西,数据量不大时手写LIMIT完全没问题,但项目里几乎所有列表页面都要分页,于是我翻了下项目原有的依赖,发现pom.xml里已经集成了PageHelper分页插件。
xml复制<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
我最早其实纠结过:直接用PageHelper的startPage和手写LIMIT+COUNT,到底该选哪个?后来一对比就明白了。手写的话,每个列表接口都要写两条SQL(一条查数据、一条查总数),而且页码计算逻辑重复度高。PageHelper的机制是,调用PageHelper.startPage(pageNum, pageSize)之后,紧接着执行的那条Mapper查询会被MyBatis拦截,自动拼接LIMIT语句,并且额外执行一条COUNT查询。代价是有一点点动态代理性能开销,需要保证startPage和紧邻的查询之间不能夹杂其他SQL操作,否则分页会错位。对业务系统来说,这个取舍完全可以接受。
2.2 返回结构设计:PageResult和Result要怎么配合
分页查询的返回结构,直接决定了前端少写多少代码。我把Day01的Result类里data字段的类型设为Object,正好可以塞一个分页结果对象。项目里统一约定分页结果用PageResult类封装:
java复制public class PageResult implements Serializable {
private long total; // 总记录数
private List<?> records; // 当前页数据集合
public PageResult(long total, List<?> records) {
this.total = total;
this.records = records;
}
}
这样Controller层返回的是Result<PageResult>,前端拿到数据后,用result.data.total渲染总页数,用result.data.records渲染表格行,结构非常直观。
对了,有一个容易犯迷糊的点:分页查询DTO(EmployeePageQueryDTO)和返回VO(EmployeeVO)的区别。查询条件是前端传过来的name、page、pageSize,它到Controller就止步了,不关心实体类有哪些字段。而返回给前端的EmployeeVO,除了实体类字段,可能还要额外带一些页面展示需要的派生字段。所以在中间的Service层我做了一次DO转VO的映射,而不是直接把Employee实体丢出去。这个习惯越早养成越好,后面报表模块的反序列化问题会少很多。
2.3 完整代码拆解:一条查询链路是怎么跑起来的
我按自己当时的代码,把这条链路完整贴一下,配合注释讲清楚每一步在干什么。
Controller层接收参数并调用Service,注意参数接收直接用实体类接收,Spring会自动把查询参数绑定进去:
java复制@RestController
@RequestMapping("/admin/employee")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
@GetMapping("/page")
public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO) {
log.info("员工分页查询,参数:{}", employeePageQueryDTO);
PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
return Result.success(pageResult);
}
}
Service层先拿到分页参数,再调用PageHelper.startPage,然后才执行select查询。这里有一个非常重要的顺序问题,下面踩坑篇我会专门讲:
java复制@Service
public class EmployeeServiceImpl implements EmployeeService {
@Autowired
private EmployeeMapper employeeMapper;
@Override
public PageResult pageQuery(EmployeePageQueryDTO dto) {
// 开始分页,必须紧接着执行下面的查询,中间不能有其他DB操作
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Employee> page = employeeMapper.pageQuery(dto);
long total = page.getTotal();
List<Employee> records = page.getResult();
// 实体对象转VO,避免直接暴露数据库字段结构
List<EmployeeVO> voList = records.stream().map(employee -> {
EmployeeVO vo = new EmployeeVO();
BeanUtils.copyProperties(employee, vo);
return vo;
}).collect(Collectors.toList());
return new PageResult(total, voList);
}
}
Mapper层用注解SQL,查询条件里带了一个动态name模糊查询:
java复制@Mapper
public interface EmployeeMapper {
@Select("SELECT * FROM employee WHERE name LIKE CONCAT('%', #{name}, '%') ORDER BY create_time DESC")
Page<Employee> pageQuery(EmployeePageQueryDTO dto);
}
这段代码有几个细节值得说道。第一,name LIKE CONCAT('%', #{name}, '%')这种写法比直接'%${name}%'安全得多,因为#{}走的是预编译,${}是字符串拼接,后者有SQL注入风险。第二,Page<Employee>作为返回值类型是PageHelper提供的,它本身是ArrayList的子类,额外多了total和pageNum这些分页元数据。Service层从Page对象里取出total和records,再包装成PageResult,这个动作不能省,因为Page对象不应该被直接返回给前端,它是分页插件的内部数据结构,跟PageResult是两套东西。
2.4 前后端联调时的完整性校验
写完接口后我先用postman测了一波,然后再跟前端页面联调。有几个边界数据是我一定会去验证的:
- 页面传的page传成1、pageSize传成10,第一页应该正常返回10条,total为总记录数
- name传一个不存在的字符串,records应该是空数组,total为0
- name不传,默认查询全部员工
- page传成0,PageHelper内部会做修正,但严格来说Controller参数校验应该在DTO上加@Min注解拦截,否则前端乱传会查到奇怪的分页结果
这里我顺手看了一下项目里的参数校验依赖,spring-boot-starter-validation是引入了的,DTO字段上可以加@NotNull、@Min这些注解。用上以后,Controller方法的参数前再加@Validated,无效请求根本进不了Service。这个习惯我希望你从Day02就开始养成,不要拖到后面接口变多了再补。
3. PageHelper的几个隐藏行为:从源码角度解释分页为什么"失效"过
3.1 一个恶心的偶现Bug:分页数据错乱
写分页查询的时候我遇到过一个特别隐蔽的问题。当时是删改一个查询逻辑,在startPage和pageQuery之间多加了一条日志打印,我寻思打个日志总不会出事吧。结果第二天测试跟我说分页数据不对,第一页返回的是第二页的数据。
我排查好久才发现问题。PageHelper的机制是,startPage执行后,把分页参数放进了ThreadLocal里,然后由MyBatis的拦截器在下一轮执行查询时读取并消费这些参数。要点在于"下一轮"。如果你在startPage之后又执行了别的查询语句,比如调试时写了一行employeeMapper.selectById(1),那么这个查询就会被当成"被分页的那条SQL",分页参数就被它消费掉了,后面真正的pageQuery反而没有分页效果。
加日志本身没问题,但如果日志里不小心触发了一个数据库查询,或者SQL语句顺序调整了,这件事就会变得很诡异。所以项目里我立了一条规矩:startPage和对应查询语句必须像两口子一样紧紧贴在一起,中间啥都不放。
3.2 COUNT查询的自动执行开销
PageHelper执行分页时,不只是拼接一条LIMIT,它还会自动生成一条COUNT查询。比如我的select语句:
sql复制SELECT * FROM employee WHERE name LIKE '%张%' ORDER BY create_time DESC
实际执行的是两条SQL:
sql复制SELECT COUNT(*) FROM employee WHERE name LIKE '%张%'
SELECT * FROM employee WHERE name LIKE '%张%' ORDER BY create_time DESC LIMIT 0, 10
两者加起来的开销,在数据量几百上千条时几乎感觉不到。但假如表里数据量达到百万级,COUNT查询走不起索引的话,会变成一个瓶颈。虽然Day02阶段用不上这些优化,但你要知道PageHelper提供了PageHelper.startPage(pageNum, pageSize, false)这种重载,第三个参数控制是否执行COUNT查询,在某些高频接口里可以手动关掉它来提性能。知道就行,别乱用。
3.3 分页结果转换时的泛型陷阱
Service层里Page<Employee> page = employeeMapper.pageQuery(dto),这里Employee是实体类。假如哪天你想在Mapper层直接返回Page<EmployeeVO>,注意这样是可行的,但需要保证VO字段能映射上查询结果。开发中更规范的做法是查询返回实体类,然后再转换成VO,因为实体类跟表结构是对齐的,VO是面向页面展示的,两者的变更频率不同,硬绑在一起后面会改得很难受。
如果实在想用Page<VO>,记得还要在Mapper的select里手写resultMap或者直接用别名对齐字段。这也是我在项目里看到别的同学一脸迷茫的原因,他不是不会写SQL,而是不清楚PageHelper和resultMap之间的协作方式。
4. 新增员工模块:参数校验、加密存储与公共字段自动填充
4.1 前端传什么,后端接什么:DTO的字段设计
新增员工的表单字段通常包括:姓名、用户名、手机号、性别、身份证号。有一个细节,用户表单里通常不会让你填密码,但后端员工表必须有初始密码。项目当前指定的逻辑是新增时默认密码为123456,并且要经过MD5加密后再落库。
第一版代码,我是把前端表单的JSON字段直接映射到Employee实体类的,用起来爽,但很快就发现不对。因为实体类是从表结构映射过来的,包含了id、createTime、status这些非前端字段,如果哪天某个接口被人恶意传入id=1,后果就是新增了一条指定主键的数据,这在某些数据库设计下会直接破坏自增主键序列。更合理的做法是用DTO接收前端参数,DTO只包含表单里允许提交的字段,然后在Service层把DTO转换到实体类。
java复制public class EmployeeDTO implements Serializable {
private String name;
private String username;
private String phone;
private String sex;
private String idNumber;
// 省略getter/setter
}
4.2 密码为什么要MD5加密,以及为什么是"教学够用"
新增员工里有个环节是设置初始密码。项目里的要求是MD5加密,代码很简单:
java复制employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
这里我想多说一句。在现代安全标准里,MD5已经不算安全的哈希算法,彩虹表攻击很容易破解弱密码。真实生产项目更推荐BCrypt这类故意减慢哈希速度的算法,每次哈希还自动带随机盐。那为什么项目里还用MD5?原因是教学项目要控制复杂度,让大家先把"不能明文存储"这个意识建立起来,密码学强度是后话。明白了这个取舍,你后续真要上线系统时就不会照抄MD5了。
4.3 ThreadLocal统一填充操作人信息,解决了登录态怎么传递的问题
这是我觉得Day02最值得反复琢磨的点。员工表里有create_user和update_user字段,需要记录操作人是谁。问题来了:我现在的登录用户信息存在JWT令牌里,校验时也在拦截器里,但Service层怎么拿到这个登录用户的id呢?
方案有很多种,最直观的是调用Controller方法时把userId作为参数传下去。但这样做,每个业务方法都要多带一个当前操作人参数,不但侵入性强,还容易漏传。项目里的做法是用ThreadLocal。
原理特别简单。拦截器在jwt解析出userId后,把它放到ThreadLocal里,后续Service层在同一个线程里执行时,随时可以取出来。一次HTTP请求在Tomcat的线程池里,一般情况下从头到尾都是同一个线程在处理,所以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 remove() {
threadLocal.remove();
}
}
拦截器里设置:
java复制Long empId = claims.get("empId", Long.class);
BaseContext.setCurrentId(empId);
在新增员工的Service里取:
java复制employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
我特别想强调两个细节。
第一个是,ThreadLocal一定要remove。Tomcat的工作线程是复用的,这次请求把值放进了ThreadLocal,如果不移除,下次这个线程被分配给另一个请求时,那个请求可能莫名其妙拿到上一次请求的用户id。项目里我是把设置和移除都放在了拦截器的preHandle和afterCompletion里,保证一次请求一个生命周期。
第二个是,虽然Day02的员工模块里能用ThreadLocal,但后面订单模块可能会用到更复杂的上下文,比如某个业务里用异步线程执行,ThreadLocal传不过去,那时候就要换成其它方案了。概念一样,先弄清楚它解决了什么问题。
4.4 公共字段填充的两种实现:手动set vs MyBatis拦截器
当我写完新增、修改接口后,突然发现自己重复了四行代码:
java复制employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId());
employee.setUpdateUser(BaseContext.getCurrentId());
两个接口写两遍,四个接口呢?这种重复很快就让人想找解决方案。项目里其实配了两种思路,我两个都试了一遍。
第一种是MyBatis的自动填充功能。在实体类的字段上配置:
java复制@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createUser;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
然后实现MetaObjectHandler:
java复制@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "createUser", Long.class, BaseContext.getCurrentId());
this.strictInsertFill(metaObject, "updateUser", Long.class, BaseContext.getCurrentId());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictUpdateFill(metaObject, "updateUser", Long.class, BaseContext.getCurrentId());
}
}
这样insert时四个字段自动填充,update时两个字段自动填充,Service层一行set都不用写。这个方案看起来更优雅,同时因为填充逻辑集中在一个类里,后面想改填充策略,比如把创建人改成从别的上下文取,改一处就行。
第二种是后面我自己扩展时用的,写一个注解配合AOP切面做填充。这个更复杂但是解耦更彻底,实体类不需要依赖MyBatis的API,Day02阶段我建议你先用自动填充,把思路理清楚。
5. 修改员工信息与启用/禁用:回显、动态SQL、状态校验
5.1 为什么修改一定先查回显
修改员工的需求拆开看是两步:
- 点击编辑按钮,页面先弹出一个带当前员工信息的表单
- 改完提交,后端执行更新
第二步的更新接口不难,难在第一步的数据回显。前端要展示当前员工资料,通常后端提供一个GET /admin/employee/{id}接口,查出员工信息返回给前端。
这个按id查询接口,返回结果我建议不要直接返回Employee实体。原因很简单:要是哪天员工表加了敏感字段,比如登录IP、最近登录时间,直接返回实体就会把不该给前端的数据也带出去。所以项目里我定义了一个EmployeeVO,字段就是页面需要的那些,用BeanUtils.copyProperties做转换,干净利落。
5.2 修改员工时的动态SQL:空值字段不更新
修改员工通常只会改动表单里有的字段,没填的字段要保留原值。这时候如果直接执行一句全量UPDATE,就会把没提交的字段全部覆盖成空。所以修改的SQL要用动态SQL,MyBatis的<set>标签就是干这个的:
xml复制<update id="update" parameterType="Employee">
UPDATE employee
<set>
<if test="name != null and name != ''">
name = #{name},
</if>
<if test="phone != null and phone != ''">
phone = #{phone},
</if>
<if test="sex != null and sex != ''">
sex = #{sex},
</if>
<if test="idNumber != null and idNumber != ''">
id_number = #{idNumber},
</if>
update_time = #{updateTime},
update_user = #{updateUser}
</set>
WHERE id = #{id}
</update>
这里有个隐含的关键点:update_time和update_user永远会被更新,它们不放if判断里面。就算业务字段一个都没改,这两个标记字段也必须要更新。否则就会出现"数据没变但'最后修改时间'没刷新"的假象。
5.3 状态切换接口:先检查再更新
启用/禁用员工,功能上就是改status字段,0禁用1启用。代码本身很简单:
java复制@PostMapping("/status/{status}")
public Result startOrStop(@PathVariable Integer status, Long id) {
log.info("启用禁用员工账号:{}, {}", status, id);
employeeService.startOrStop(status, id);
return Result.success();
}
Service层要做的就是在更新前加一道检查。比如你要禁用的员工你还得看看他是否存在,是否存在会被外键关联,虽然employee表没有外键约束,但业务上查一下总没错:
java复制public void startOrStop(Integer status, long id) {
Employee employee = employeeMapper.getById(id);
if (employee == null) {
throw new BusinessException("员工不存在");
}
employee.setStatus(status);
employeeMapper.update(employee);
}
到这里,Day02的员工管理四大模块就都落地了。
5.4 关于Mapper方法命名的一点建议
写crud代码时,我经常发现新人会写出各种奇怪的命名习惯,比如updateEmp、updateEmployeeInfo、updateEmployeeById。我后来给自己定了个简单的命名规则,尽管不是很花哨,但对后期排查特别有帮助:
- 按id查一个:getById
- 按条件查列表:listBy... 或 pageQuery
- 插入:insert 或 save
- 更新:update 或 updateById
- 删除:deleteById
不要小看命名,Day02可能觉得无所谓,到了Day05、Day06,一个类里十几个Mapper方法,根本记不住。越是看起来简单的东西,越要提前立规矩。
6. 踩坑实录:这五个问题我花了比写接口更长的时间
6.1 BeanUtils.copyProperties的属性名不一致
新增和修改功能里,我把前端DTO往实体类转换,用的最顺手的就是org.springframework.beans.BeanUtils.copyProperties(dto, entity)。它本质上是反射字段拷贝,同名同类型的属性复制过去,类型不一致或者名字不一致就静默跳过。
我踩过的坑是:DTO里字段叫idNumber,实体类里叫idCard,两个Javabean反射比对时发现字段名对不上,结果身份证号就是传不进去。数据库的id_number字段最后一直是null。排查了大半天,最后一行一行对字段名才发现。
这个教训让我养成了一个习惯:用copyProperties之前,先写测试或者打个日志确认每一对字段都拷贝上了,尤其注意驼峰和数据库下划线之间的映射,对象拷贝本身可不管数据库那套规则。
6.2 日期格式和前端传值不匹配
新增员工接口我一开始没有仔细看前端传的时间类型。有些表单里的日期是字符串"2024-05-01 12:00:00",DTO里接收字段却是LocalDateTime。Spring Boot默认的Jackson序列化配置,对这种格式能处理,但如果前端给的是"2024-05-01T12:00:00"这种ISO格式,直接就会报一个JSON解析错误。
碰到这种情况,要么前端统一一下格式,要么后端对字段加注解:
java复制@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
我在项目里是两者都做了,后端加了@JsonFormat兜底,前端固定传"yyyy-MM-dd HH:mm:ss"格式。
6.3 前端页面回显的是时间,后端查询出来是null
这个坑严格来说是字段映射问题。分页查询返回的EmployeeVO里如果包含createTime字段,而查询SQL里没有查询这一列,前端表格就怎么也显示不出创建时间。我当时查SQL都是select *,前端也显示正常,后来改成显式列名就差点漏掉create_time。我的建议是,初学阶段无脑select *问题不大,但自己要知道,线上项目尤其是表字段特别多的时候,select *带出无用字段,既浪费IO又可能因字段变更引发序列化错误。该显式列名就要显式。
6.4 状态更新后,前端列表不刷新
这个不是后端bug,而是联调接口时的交互问题。前端列表页用了本地缓存的分页数据,状态切换成功后没重新拉取列表。后端把status字段更新成0了,前端页面依然显示"启用中"。
联调时一定要在同一个环境里验证,不只是接口返回success,还得看列表数据的刷新链路通不通。这种问题看起来是前端的事,但后端在设计接口返回结构时,如果能考虑"变更后前端是否方便重新查询",整个协作会顺畅很多。
6.5 请求参数大小写导致数据库查不到
中间有一次前端用axios传参,name=Alice,我后端查询SQL明明写的正确,结果列表为空。后来抓到请求参数,发现前端把参数名传成了Name,而不是name。Spring MVC默认的大小写敏感直接把查询条件丢了。
这个问题其实很好避免,接口联调前先用postman把请求体格式定下来,再让前端照着填,能少很多类似的"灵异事件"。
7. 阶段总结:Day02做完之后我脑子里留下了什么
Day02整体来说是一个承上启下的模块。说承上,是因为它用到了Day01的JWT、ThreadLocal和Result;说启下,是因为它引入了分页查询、公共字段填充、动态SQL这三个后续每个模块都会反复用到的东西。
我的个人建议是,完成这一天的代码后,不要急着往Day03赶。你有条件的话,可以把这几个练习再做一遍:
- 给分页查询加上按状态筛选,看看动态SQL该怎么扩展
- 用MyBatis自动填充把新增、修改里的公共字段set代码全部去掉
- 把新增员工逻辑里的username唯一约束冲突处理掉,返回友好提示而不是让数据库异常冒出来
- 给DTO加上参数校验注解,让非法请求在进入Service之前就被拦截
这些都是很小的改动,但做完之后你对这个模块的理解会比照着视频敲一遍深刻很多。我印象里Day02是苍穹外卖项目里第一个让我有"原来接口是这么一层一层串起来的"感觉的阶段,希望你也能在这天把Controller、Service、Mapper这条链路彻底盘顺。
