苍穹外卖day02实战:员工管理、分页查询与图片上传避坑全解析

发布时间:2026/10/3 18:02:20
苍穹外卖day02实战:员工管理、分页查询与图片上传避坑全解析 如果你正在跟练“苍穹外卖”这套系列实战项目day02应该是一个让你真正开始写业务代码的节点。前面day01把登录鉴权和项目骨架搭完了第二天直接进入员工管理新增员工、分页查询、编辑、启停、删除外加一套通用能力。很多人会觉得“这不就是CRUD吗”但实际跑下来这一天埋的坑一点也不少尤其是分页返回结构、状态字段的处理、密码加密、参数校验这些细节直接决定后面菜品、套餐、分类模块能不能顺畅往下写。这篇文章我就按自己实操的顺序把day02整体过一遍从任务拆解到接口实现再到联调阶段真实翻过的车一次性说清楚。顺便把很多人问过的“苍穹外卖本地上传图片”这个点也串进来讲明白它在day02里的位置和落地方式。1. day02到底在做什么员工管理模块的任务拆解与前置准备1.1 第二天的任务边界不是单纯的CRUDday02表面上做的是员工的基本操作也就是平常说的增删改查但它的价值主要体现在两个地方。第一它是整个管理端后台的第一个完整业务模块后面所有模块——分类、菜品、套餐、订单——都会沿用同一套接口规范、返回结构和异常处理方式。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泛型设计成PageResultT这样后面菜品分页、订单分页都能复用。Controller层接收参数后调用ServiceService里用PageHelper分页最后把结果塞进PageResult。这里要特别提醒一个容易翻车的点很多人分页查出来后直接把list返回给前端然后在Service里又对list做了一遍内存过滤结果total和实际数据对不上翻页翻着翻着就少了数据。正确的做法是分页插件只负责SQL层面的分页你在Service里不应该再对分页后的结果做二次过滤所有过滤条件都应该下沉到Mapper的SQL里。2.2 动态SQL的写法与空值判断name是可选参数也就是说用户不输入姓名时查全部输入姓名时要按姓名模糊匹配。这个需求用动态SQL实现最合适。我用的方式是Mapper接口加XML在XML里写selectselect idpageQuery resultTypecom.sky.entity.Employee select * from employee where if testname ! 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); ListEmployee list employeeMapper.pageQuery(name); PageEmployee p (PageEmployee) 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: GMT8另一种是字段级别的注解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接口只用status0禁用来达到“员工不可用”的效果。day02如果要求物理删除才能过测试那课程阶段照做没问题但你自己要知道到了做个人项目或者实习接真实需求时逻辑删除才是更稳妥的方案。4.3 自禁用和关联数据两个容易被忽视的边界启用/禁用看似简单但有两个边界值得多说几句。第一个是“不能禁用自己”。现实中一个后台管理员不应该能把自己账号禁用否则可能出现操作完自己直接掉线的尴尬情况。实现上可以在Service里加判断如果操作人id等于目标员工id抛异常“不能禁用当前登录账号”。课程里一般不做这个但面试时主动提出来能加分。第二个是“禁用后的登录”。禁用员工之后该员工应该无法再登录系统。这个逻辑通常不在day02里写而是day01登录接口的扩展登录时除了校验密码还要查一下status如果是0直接提示“账号已被禁用”。如果你day02做完之后顺手把登录那里的状态判断补上整个链路才是完整的否则就会出现“员工停用了还能正常登录”的bug。5. 员工操作的通用能力补齐统一返回、参数校验与日志埋点5.1 统一返回Result与全局异常处理器day02的接口如果每个都自己try-catch代码会非常难看。项目里一般会定义一个统一返回类Result里面包含code、msg、data三个字段成功时code1失败时code0。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接收MultipartFileService处理存储逻辑PostMapping(/upload) public ResultString 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); ListEmployee list employeeMapper.pageQuery(name); PageInfoEmployee pageInfo new PageInfo(list); return new PageResult(pageInfo.getTotal(), pageInfo.getList());PageInfo内部会处理分页数据和total的获取避免强转带来的隐患我建议day02就养成这个习惯。7.3 状态开关永远反着前端Switch组件显示“启用”但后端status1就是对的状态。联调时经常出现前端拿到1却显示成“禁用”原因是前端把status当成了Boolean处理1 true判断为false开关就关了。解决方法有两个。一是前端在拿到数据后把status转成Boolean再绑定到Switch上提交时再转回数字二是前端直接和后端约定status就是numberSwitch组件的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的代码量不算多但每一个接口背后都带着一套设计规范。我个人的建议是不要只满足于把接口调通而是每写完一个功能就回头问一句返回结构统一吗异常处理了吗参数校验了吗权限边界考虑了吗把这些习惯在第二天就固化下来后面写订单模块时会轻松非常多。至于本地上传图片它是你第一次接触文件上传到静态资源映射的完整链路理解清楚之后再切换到云存储方案时才会觉得水到渠成。