如果你正在跟练“苍穹外卖”这套系列实战项目,day02应该是一个让你真正开始写业务代码的节点。前面day01把登录鉴权和项目骨架搭完了,第二天直接进入员工管理:新增员工、分页查询、编辑、启停、删除,外加一套通用能力。很多人会觉得“这不就是CRUD吗”,但实际跑下来,这一天埋的坑一点也不少,尤其是分页返回结构、状态字段的处理、密码加密、参数校验这些细节,直接决定后面菜品、套餐、分类模块能不能顺畅往下写。这篇文章我就按自己实操的顺序把day02整体过一遍,从任务拆解到接口实现,再到联调阶段真实翻过的车,一次性说清楚。顺便把很多人问过的“苍穹外卖本地上传图片”这个点也串进来,讲明白它在day02里的位置和落地方式。
1. day02到底在做什么:员工管理模块的任务拆解与前置准备
1.1 第二天的任务边界:不是单纯的CRUD
day02表面上做的是员工的基本操作,也就是平常说的增删改查,但它的价值主要体现在两个地方。
第一,它是整个管理端后台的第一个完整业务模块,后面所有模块——分类、菜品、套餐、订单——都会沿用同一套接口规范、返回结构和异常处理方式。day02把事情做顺了,后面就是复制粘贴再加细节;day02偷懒了,后面每个模块都会来填坑。
第二,day02会接触一批“看起来不起眼但面试必问”的点:分页插件怎么用才不会弄丢数据、密码存库之前为什么要加密、状态字段用0和1而不是Boolean、删除员工到底走物理删除还是逻辑删除、文件上传到本地之后前端为什么还是访问不到。这些问题单拎出来每一个都能写一篇文章,day02把它们一次性集中到了同一个业务场景里。
课程里员工管理通常包含五个接口:新增员工、员工分页查询、根据id查询员工(编辑回显用)、编辑员工、启用禁用员工。有的版本还会额外加一个删除员工接口。做这些接口之前,你需要先确认前置知识已经到位。
1.2 前置知识回顾:JWT登录、ThreadLocal和员工表结构
day01做好的两样东西在day02里会反复用到。
一个是JWT登录。员工登录成功后会拿到一个token,后续所有请求通过拦截器校验token,解析出当前登录员工的id。这个id在day02的业务里很重要,因为新增员工、编辑员工时,需要记录这条数据是谁创建的、谁最后修改的。
另一个是ThreadLocal。day01通常会在拦截器里把当前登录员工id存入ThreadLocal,提供一个BaseContext工具类来读写。day02写Service的时候,直接调BaseContext.getCurrentId()就能拿到操作人id,然后用它填充createUser和updateUser字段。这个设计不是花架子,真实项目里的审计字段基本都是这么来的。
员工表的结构也比较典型,字段大致如下:
- id:主键,自增
- name:员工姓名
- username:登录账号
- password:加密后的密码
- phone:手机号
- sex:性别
- id_number:身份证号
- status:状态,1启用,0禁用
- create_time:创建时间
- update_time:更新时间
- create_user:创建人
- update_user:修改人
注意,这里的status在数据库里是int类型,不是Boolean。之所以用0和1而不是true/false,是因为状态字段以后有可能会扩展,比如2表示冻结、3表示已注销,Boolean就撑不住了。这是day02就要养成的设计习惯:状态位用数字枚举,别图省事用Boolean。
2. 员工分页查询:条件搜索、分页插件与日期格式化的一次性落地
2.1 接口设计与PageResult封装
员工分页查询是day02里信息量最大的一个接口。它的入参有三个:page(当前页码)、pageSize(每页条数)、name(员工姓名,可选)。返回的是一个分页结果对象,里面包含总记录数total和当前页数据records。
我习惯先把返回结构封装好。定义一个PageResult类,属性就是total和records,泛型设计成PageResult<T>,这样后面菜品分页、订单分页都能复用。Controller层接收参数后调用Service,Service里用PageHelper分页,最后把结果塞进PageResult。
这里要特别提醒一个容易翻车的点:很多人分页查出来后,直接把list返回给前端,然后在Service里又对list做了一遍内存过滤,结果total和实际数据对不上,翻页翻着翻着就少了数据。正确的做法是,分页插件只负责SQL层面的分页,你在Service里不应该再对分页后的结果做二次过滤,所有过滤条件都应该下沉到Mapper的SQL里。
2.2 动态SQL的写法与空值判断
name是可选参数,也就是说用户不输入姓名时,查全部;输入姓名时,要按姓名模糊匹配。这个需求用动态SQL实现最合适。我用的方式是Mapper接口加XML,在XML里写<select>:
<select id="pageQuery" resultType="com.sky.entity.Employee"> select * from employee <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> </where> order by create_time desc </select>这里有一个非常容易踩的坑:like的写法。新手经常写like '%#{name}%',这在MyBatis里是跑不出来的,因为#{}会被解析成预编译参数占位符,放在字符串里面就变成了'%?%',数据库不认识。正确写法是like concat('%', #{name}, '%'),让数据库自己去拼接。
分页这步我推荐直接用PageHelper。Service里的写法是:
public PageResult pageQuery(int page, int pageSize, String name) { PageHelper.startPage(page, pageSize); List<Employee> list = employeeMapper.pageQuery(name); Page<Employee> p = (Page<Employee>) list; return new PageResult(p.getTotal(), p.getResult()); }注意,PageHelper.startPage()之后紧接着的那一条查询语句会被拦截并加上limit,所以中间不能再插入别的数据库操作,否则分页会作用到错误的查询上。这是PageHelper最经典的线程安全问题,也是联调时最常见的翻车位置之一。
2.3 LocalDateTime序列化:为什么前端拿到的是“一串数字”
分页查询跑通之后,打开接口文档,你会发现列表里的createTime显示的不是“2025-01-01 10:00:00”,而是一串数字,类似1735689600000。这是因为Spring Boot默认用Jackson序列化LocalDateTime时,会把它转成时间戳。
这个问题的修复方式有两种。一种是全局配置,在application.yml里统一设置日期格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8另一种是字段级别的注解:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;我建议优先用全局配置。因为项目里的时间字段很多,每个字段都加注解太啰嗦,而且容易漏。全局配置一次,所有LocalDateTime都会按指定格式输出,后面做菜品、订单模块时就不用再操心了。
3. 新增与编辑员工的套路复用:密码加密、唯一性校验和字段保护
3.1 新增员工时密码和默认状态的处理
新增员工是day02里第一个写“写操作”的地方。前端传过来的参数包括姓名、账号、手机号、身份证号、性别等,但不会传密码和状态。后端的逻辑是:设置默认密码为123456,设置默认状态为启用,创建人和修改人都取当前登录员工id。
这里最大的坑是密码加密。如果你直接把前端传过来的密码明文存进数据库,那登录功能第二天就废了。day01登录时校验的是加密后的密码,存库的也必须是加密后的密码。项目里一般用MD5加密,简单直接:
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));当然,MD5在今天的安全标准里已经不太够看了,真实项目我建议用BCrypt这类加盐哈希算法。但课程阶段用MD5能跑通逻辑,等做个人项目时再升级加密方式也不迟。
新增员工的另一个细节是:当前登录人的id要填充到createUser和updateUser里。代码很简单:
employee.setCreateUser(BaseContext.getCurrentId()); employee.setUpdateUser(BaseContext.getCurrentId());不要小看这两行,后面做操作日志、数据审计、谁创建了谁这种统计时,全靠它们。
3.2 username唯一性校验:查询兜底与索引兜底
新增员工时必须校验账号不能重复。业务上通常的做法是:根据username查一遍数据库,如果已经存在,直接抛业务异常,提示“账号已存在”。
if (employeeMapper.getByUsername(employee.getUsername()) != null) { throw new BusinessException("账号已存在"); }这种先查再插的方式在单机低并发场景下没问题,但并发高时会存在竞态条件:两个请求同时查到不存在,然后都插入成功。所以我在实际项目中还会在数据库层面加唯一索引兜底,username建unique index。插入时如果报了DuplicateKeyException,再转成业务异常返回给前端。课程阶段不需要做到这个程度,但你心里要清楚这个边界。
3.3 编辑回显:为什么不返回password
编辑员工一般分两步:回显和提交。回显就是根据id查员工信息,返回给前端填充表单;提交就是前端把修改后的数据传回来,后端执行update。
回显接口有一个禁止做的事:不要把password返回给前端。即使密码是密文,也不应该出现在查询结果里,否则前端拿到之后如果在接口文档页面直接展示,等于把密码明文暴露给了所有能看到接口的人。处理方式很简单,查询后把password置空,或者在Mapper查询时不查这个字段。我推荐后者,查询SQL里明确列出需要的字段,而不是select *。
编辑提交时也一样,员工DTO里不应该有password字段,这样即使前端误传了,后端也不会去更新它。DTO和实体类分离的意义就在这:数据库里有什么字段、前端能传什么字段、接口返回什么字段,三者可以各不相同,互不干扰。
4. 启用/禁用与删除:状态位设计、逻辑删除的真实业务考量
4.1 状态字段的0/1语义和前端联动
启用/禁用员工是day02里看着最简单、实际最容易出幺蛾子的接口。它的本质就是根据id把status字段更新为0或1。后端只需要一个方法:
@PostMapping("/status/{status}") public Result startOrStop(@PathVariable Integer status, Long id) { employeeService.startOrStop(status, id); return Result.success(); }为什么用路径参数而不是请求体传status?因为这个接口的语义就是“把某员工切到某状态”,路径参数更符合REST风格,前端调用也方便。当然,前端到底是传1表示启用还是传0表示启用,这个一定要和后端对齐,否则就会出现“前端点了启用,列表反而变成禁用”的诡异现象。
前端联调时状态开关经常反着,原因有两类。一类是前端用Switch组件时的activeValue和inactiveValue没配好,Swtich默认是true/false,而后端给的是1/0,类型对不上。另一类是后端返回的status被Jackson反序列化成了Boolean,前端拿到的是true,但提交时又传0/1,两边模型不一致。所以我在项目里会明确要求:状态字段前后端一律用数字0/1,不搞Boolean转换,省得来回踩坑。
4.2 物理删除与逻辑删除,什么时候该切换
关于删除员工,课程里有的版本会做一个delete接口,直接把员工从表里删掉。这种做法的好处是简单,但如果你准备把这个项目写到简历上,我强烈建议你把删除改成逻辑删除。
原因很简单:员工不是孤立数据。一个员工可能创建过菜品、处理过订单,如果把这些关联数据快照里的操作员名字删掉,历史记录就会变成空。真实电商后台几乎没有“删除用户”这种操作,全部是“停用”或“注销”,目的就是保留数据链路的完整性。
逻辑删除的实现方式有两种。一种是在表上加is_deleted字段,查询时统一加where is_deleted = 0;另一种是干脆不做delete接口,只用status=0禁用来达到“员工不可用”的效果。day02如果要求物理删除才能过测试,那课程阶段照做没问题,但你自己要知道,到了做个人项目或者实习接真实需求时,逻辑删除才是更稳妥的方案。
4.3 自禁用和关联数据:两个容易被忽视的边界
启用/禁用看似简单,但有两个边界值得多说几句。
第一个是“不能禁用自己”。现实中,一个后台管理员不应该能把自己账号禁用,否则可能出现操作完自己直接掉线的尴尬情况。实现上可以在Service里加判断:如果操作人id等于目标员工id,抛异常“不能禁用当前登录账号”。课程里一般不做这个,但面试时主动提出来,能加分。
第二个是“禁用后的登录”。禁用员工之后,该员工应该无法再登录系统。这个逻辑通常不在day02里写,而是day01登录接口的扩展:登录时除了校验密码,还要查一下status,如果是0,直接提示“账号已被禁用”。如果你day02做完之后顺手把登录那里的状态判断补上,整个链路才是完整的,否则就会出现“员工停用了还能正常登录”的bug。
5. 员工操作的通用能力补齐:统一返回、参数校验与日志埋点
5.1 统一返回Result与全局异常处理器
day02的接口如果每个都自己try-catch,代码会非常难看。项目里一般会定义一个统一返回类Result,里面包含code、msg、data三个字段,成功时code=1,失败时code=0。Controller的每个方法都返回Result,前端拿到code之后再做分支处理。
那业务异常怎么处理?不用在Controller里手写if-else返回Result.error(),而是把校验逻辑放在Service里,发现异常就抛BusinessException,然后由ControllerAdvice统一捕获,转成Result.error(msg)。这个模式的好处是Controller变得非常干净:
@PostMapping public Result save(@RequestBody EmployeeDTO employeeDTO) { employeeService.save(employeeDTO); return Result.success(); }所有校验逻辑都藏在Service里,代码读起来一目了然。全局异常处理器里通常还要单独捕获参数校验异常、SQL异常、未知异常,分别返回不同提示,避免直接把数据库报错信息甩给前端。
5.2 参数校验:DTO层面的@Validated
新增员工时,姓名、账号、手机号都是必填的。这种校验如果用if-else在Service里写,每个字段都要写一遍,代码很啰嗦。正确做法是在DTO字段上加校验注解:
public class EmployeeDTO { @NotBlank(message = "姓名不能为空") private String name; @NotBlank(message = "账号不能为空") private String username; private String phone; private String sex; private String idNumber; }Controller入参加@Validated注解,校验失败时会抛MethodArgumentNotValidException,由全局异常处理器统一转换成“字段名+错误信息”的提示返给前端。
这里有一个小技巧:新增和编辑的DTO最好分开定义,或者用分组校验。因为新增时username必填,但编辑时username可能不允许修改;新增时密码有默认值,编辑时表单里根本没有密码输入框。硬要一个DTO通吃,校验逻辑会越写越乱。分开写虽然多几个类,但边界清晰,后面扩展字段时也方便。
5.3 操作日志:AOP记录谁在什么时候干了什么
员工管理是管理端操作,正式上线之后运营人员干了什么必须留痕。day02很多教程不会要求做操作日志,但我会建议你顺手把AOP切面搭起来。思路很简单:定义一个@Log注解,标注在需要记录日志的Controller方法上,然后用AOP切面拦截方法,记录请求路径、方法名、操作人、操作时间、参数、耗时。
@Around("@annotation(com.sky.annotation.Log)") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { long begin = System.currentTimeMillis(); // 记录操作人、请求参数等 Object result = joinPoint.proceed(); // 记录耗时、响应结果 return result; }这一步在day02做的好处是,后续菜品、套餐、订单的操作日志直接复用同一个切面就行。日志表也不需要很复杂,字段就是操作人id、操作人姓名、操作类型、方法名称、请求参数、耗时、操作时间,最多再加个IP。做完这件事,简历上的项目描述里就能多一句“通过AOP实现操作日志埋点,支持后台操作审计”,这是实打实的亮点。
6. 本地上传图片:从文件到URL的完整链路——day02里最容易单独拎出来问的点
6.1 为什么day02就要接触文件上传
员工管理本身不太需要图片,但后续的员工头像、菜品图片、套餐图片、分类图标,全都依赖文件上传能力。所以在day02或day03的练习里,经常会安排一个通用的上传接口,先把文件收到服务器本地目录,再返回可访问的URL。这就是“苍穹外卖本地上传图片”这个热词出现的原因——它不是某个隐藏功能,而是day02文件上传练习的通用说法。
本地存储只是第一步,等后面学云存储时,把上传落地的部分替换成OSS或云存储SDK,接口返回逻辑完全不用变。
6.2 本地存储实现:写文件、生成URL、静态资源映射
上传接口的写法很固定,Controller接收MultipartFile,Service处理存储逻辑:
@PostMapping("/upload") public Result<String> upload(MultipartFile file) { // 1. 生成唯一文件名 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String newFileName = UUID.randomUUID().toString() + ext; // 2. 保存到本地目录 String basePath = "/Users/sky/upload/"; File dir = new File(basePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(basePath + newFileName)); // 3. 返回可访问的URL return Result.success("http://localhost:8080/upload/" + newFileName); }文件名为什么要用UUID重命名?因为原始文件名可能是中文、可能包含空格、可能两个人传了同名文件,直接用原始文件名很容易互相覆盖。加个UUID可以保证不重名。后缀名建议保留,因为图片类型可以通过后缀快速判断,但后缀一定要做白名单过滤,后面第6.4节会讲。
文件存到本地之后,最大的坑来了:浏览器访问http://localhost:8080/upload/xxx.jpg时,Spring Boot默认不会把这个路径映射到你的磁盘目录,结果就是404。解决办法是配置静态资源映射:
@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:/Users/sky/upload/"); } }这个配置的意思是:所有以/upload/开头的请求,都去磁盘的/Users/sky/upload/目录找文件。配置完之后,上传的图片才能真正通过URL访问到。
6.3 大小限制、后缀白名单与路径安全
本地文件上传的边界条件比业务接口多。第一个是大小限制,Spring Boot默认上传文件最大1MB,超出会直接报错。你可以在application.yml里调大:
spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB第二个是后缀白名单。不能什么文件都收,至少要把图片格式限制住:
String whitelist = ".jpg,.jpeg,.png,.gif,.bmp"; if (!whitelist.contains(ext.toLowerCase())) { throw new BusinessException("文件格式不正确"); }第三个是路径安全。不要直接使用前端传来的原始文件名来拼路径,否则可能会有路径穿越问题;用UUID重命名可以顺手把这个风险也消掉。
本地存储毕竟有上限,磁盘满了、应用重启、多机部署时文件不在同一台机器上,都会出问题。所以day02告诉你本地怎么传,你只要理解整条链路;真正上线时,要把存储层替换成云对象存储,让文件走HTTP直传或者服务端上传到远端存储桶。这个升级后续学到时再切,接口层几乎零改动。
7. 联调期最容易翻车的几个点:第二天实跑出来的坑与修正
7.1 时间字段变成“一串数字”
这个问题在分页查询部分已经说过原理,我再强调一下联调时的表现:前端列表页显示createTime是一串毫秒值,怎么格式化都没用。本质就是后端返回的JSON里时间本身就是一个long。如果你改了全局Jackson配置还不生效,先确认Spring Boot版本是不是3.x,有些版本里spring.jackson.date-format对LocalDateTime不生效,需要在ObjectMapper里注册JavaTimeModule并设置LocalDateTimeSerializer。最快的检查方式是看返回结果里时间是数字还是字符串。
7.2 PageHelper分页后total不对
联调时最容易出现的分页异常有几种:total一直是0、页数对但数据错乱、翻页时偶尔数据重复。排查思路按顺序来:
- 先确认PageHelper依赖已经引入,且配置了拦截器插件的Dialect,不指定会默认按数据库类型推断。
- 再确认
startPage()之后立刻执行了查询,中间没有插入其他Mapper调用。 - 最后确认Service返回的list没有被包装成ArrayList,否则强转Page失败会报ClassCastException。
这里有个更稳的写法:用PageInfo包装结果,不依赖强转:
PageHelper.startPage(page, pageSize); List<Employee> list = employeeMapper.pageQuery(name); PageInfo<Employee> pageInfo = new PageInfo<>(list); return new PageResult(pageInfo.getTotal(), pageInfo.getList());PageInfo内部会处理分页数据和total的获取,避免强转带来的隐患,我建议day02就养成这个习惯。
7.3 状态开关永远反着
前端Switch组件显示“启用”,但后端status=1就是对的状态。联调时经常出现前端拿到1却显示成“禁用”,原因是前端把status当成了Boolean处理,1 == true判断为false,开关就关了。
解决方法有两个。一是前端在拿到数据后把status转成Boolean再绑定到Switch上,提交时再转回数字;二是前端直接和后端约定,status就是number,Switch组件的activeValue设为1、inactiveValue设为0。第二种更简单,推荐直接约定数字模型。
7.4 图片上传成功但访问404
上传接口返回了“http://localhost:8080/upload/xxx.jpg”,结果访问404。这个问题的排查链路很清晰:先打开返回的URL,看报错是404还是403还是500。404基本就是静态资源映射没配置,检查有没有WebMvcConfiguration里addResourceHandlers的代码,以及配置的磁盘路径和实际保存路径是否一致。最容易忽略的是路径末尾的斜杠,Linux下漏了斜杠会导致拼接路径出错。
如果上传目录在项目之外的绝对路径,一定用file:前缀。不加file会被当成classpath路径来解析,文件永远找不到。
7.5 密码明文入库,登录时永远密码错误
联调时如果你直接拿day01注册的接口去登录,然后顺手把数据库里的password字段改成明文,那day02登录校验永远不会通过。因为day01登录时拿输入的密码做同样的加密运算后和库里的密文比对,你手动改成明文,就对不上了。
正确的做法是:新增员工时后端自动加密,不要手动改数据库。联调数据想造一条能登录的员工,就调用一次登录接口,或者复用一个已知密文的密码。课程里默认密码是123456,加密结果形如e10adc3949ba59abbe56e057f20f883e,你可以拿这条数据直接测试。
7.6 删除员工报了外键关联错误
如果你的项目数据库建了外键,删除员工时可能会报Cannot delete or update a parent row: a foreign key constraint fails。这是因为别的表里存在关联数据。课程阶段如果只是纯练习,可以先把外键约束去掉;但如果要把项目沉淀成作品,建议把物理删除改成逻辑删除,加is_deleted字段,查询统一过滤。这样既保住数据关系,又能满足“删除”语义。
总而言之吧,day02的代码量不算多,但每一个接口背后都带着一套设计规范。我个人的建议是,不要只满足于把接口调通,而是每写完一个功能就回头问一句:返回结构统一吗?异常处理了吗?参数校验了吗?权限边界考虑了吗?把这些习惯在第二天就固化下来,后面写订单模块时会轻松非常多。至于本地上传图片,它是你第一次接触文件上传到静态资源映射的完整链路,理解清楚之后,再切换到云存储方案时才会觉得水到渠成。