我没少在苍穹外卖这类教学项目上折腾。day02员工基本操作听起来简单,实际写起来涉及的状态管理、参数校验、分页查询、文件上传,哪个都有坑。这篇就按我实际敲代码的顺序,把新增员工、分页查询、启用禁用、编辑员工以及本地上传图片这些核心操作拆开讲清楚,包含每一步的设计理由和实操细节,给正在做这个项目的朋友一份可以直接照着干的参考,也顺便把那些文档里不会写的坑提前说出来。
1. 今天要做什么:员工管理模块的整体设计思路
1.1 先看清day02在苍穹外卖里的位置
苍穹外卖这个项目的整体流程是从员工登录开始的,day01通常已经把后端骨架搭好,包括Spring Boot工程、MyBatis环境、JWT登录认证、全局异常处理这些基础设施。到了day02,核心任务就落到员工管理模块上。这个模块是整个后台管理系统的基础,因为任何业务操作都离不开管理员和员工账号,没有这套员工管理功能,后面做菜品、套餐、订单管理时连基本的权限和责任人字段都没法填。
员工管理包含几块内容:新增员工、员工分页查询、启用禁用员工状态、编辑员工信息。这四个操作几乎覆盖了最常见的后台管理场景,也是后面对其他业务模块进行CRUD开发时可以直接复用的模板。换句话说,day02并不是只做一个简单的增删改查,而是通过员工这个实体把一套规范的后端开发流程练熟:接收请求参数、调用Service业务逻辑、操作Mapper访问数据库、统一返回结果、处理异常。
1.2 为什么这么设计:从Controller到Mapper的分层结构
苍穹外卖采用的是经典的三层架构:Controller层负责接收前端请求和参数校验,Service层负责业务逻辑和事务控制,Mapper层负责数据库操作。很多同学在刚开始写代码时会觉得这种分层很啰嗦,一个简单的新增员工也能写成三层。但等需求变复杂你就明白了,如果不分层,所有逻辑堆在一起,改一个需求就要动一片代码,出了问题也很难定位。
举个例子,新增员工时前端传来的请求参数是JSON格式,包含用户名、姓名、手机号、身份证号等,如果直接在Controller里把这些参数手动拼接成SQL语句,代码会非常难看,而且安全问题也很多。正确的做法是定义一个EmployeeDTO对象来接收参数,在Service层进行用户名唯一性校验、密码初始化、状态默认值设置,最后把Employee实体传给Mapper去执行insert操作。这样每一层各司其职,测试和维护都会轻松很多。
另外一个值得注意的设计是实体类与DTO分离。在苍穹外卖的项目代码里,Employee实体对应的是数据库表结构,里面的字段和表字段一一对应,比如createTime、updateTime、status等。而前端传来的参数可能不是完整的实体字段,比如新增员工时不会传createTime和status,如果直接用实体去接收JSON,很多字段就会是null,再被误写入数据库。所以day02里的规范做法是创建EmployeeDTO来接收前端数据,在Service里手动补全实体字段,再完成插入。
1.3 前置准备:确认你的表结构和依赖配置
动手写代码之前,先确认数据库表是否已经创建好。苍穹外卖的员工表一般叫employee,核心字段包括id、username、name、password、phone、sex、id_number、status、create_time、update_time、create_user、update_user。其中username是唯一的登录名,status是1表示启用、0表示禁用,create_user里面存的是当前登录员工对应的ID。
pom.xml里需要确认的依赖有几个:Spring Web、MyBatis框架(苍穹外卖用的是MyBatis)、MySQL驱动、Lombok、Knife4j用于接口文档。分页查询我用的是PageHelper插件,这个组合在绝大多数教学项目中是标配,配置也很简单,只要在启动类上声明PageHelper的MyBatis插件bean就行。比如下面这段配置就是标准操作:
@Bean public PageInterceptor pageInterceptor() { PageInterceptor pageInterceptor = new PageInterceptor(); Properties properties = new Properties(); properties.setProperty("helperDialect", "mysql"); properties.setProperty("reasonable", "true"); pageInterceptor.setProperties(properties); return pageInterceptor; }这里的reasonable=true很关键,意思是当页码小于1时自动改成查询第一页,超过最大页数时自动改成最后一页。实操中很多前端分页组件都会出现页码越界的请求,不设置这个参数就会直接报错,设置完之后系统会自动处理。
2. 新增员工:看似简单,细节却不少
2.1 新增员工的整体流程拆解
新增员工这个功能可以拆成前端操作、后端接收、业务处理、数据库插入四段。前端在员工管理页面点击"新增员工"按钮,填写用户名、姓名、手机号、身份证号,然后提交POST请求到/admin/employee接口。后端要做的事情不只是insert这么简单,因为员工登录时需要用账号密码验证身份,所以新增员工时必须给一个初始密码;用户名不允许重复,否则系统会混乱;身份证号、手机号有校验规则,不能瞎填。
我在敲这个功能时习惯在Service层把逻辑顺序固定下来:先检查用户名是否重复,再补齐员工实体的字段,设置初始密码和默认状态,最后执行插入。顺序不要乱,否则可能会先插进去一条数据,然后才发现用户名重复,导致抛异常时数据已经污染了。
2.2 Controller和DTO的写法与参数校验
Controller层的代码不长,核心就是接收EmployeeDTO、调用Service层方法、返回统一结果。苍穹外卖项目里的统一返回结果类叫做Result,有success方法可以快速构建成功响应。新增员工成功后,一般返回Result.success(employee)或者Result.success()。
DTO里我加的校验注解配合Knife4j文档,体验很好。@NotBlank用来保证用户名、姓名不能为空,手机号和身份证号用@Pattern做正则校验。实际测试时发现,如果只做后端业务逻辑校验而不做参数格式校验,手滑传一个不符合格式的手机号进去,数据库里的数据就会非常脏,后续做查询、导出时都会出问题。所以Controller在接收参数时就拦住错误请求,比在Service层再判断要早一步。
这里给出一段Controller示例代码:
@PostMapping("/admin/employee") @ApiOperation("新增员工") public Result save(@RequestBody EmployeeDTO employeeDTO) { employeeService.save(employeeDTO); return Result.success(); }对应的DTO定义:
@Data public class EmployeeDTO implements Serializable { private Long id; @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "姓名不能为空") private String name; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; private String sex; @Pattern(regexp = "^\\d{17}[0-9Xx]$", message = "身份证号格式不正确") private String idNumber; }2.3 Service层的核心业务逻辑
Service层才是新增员工的主战场。我在写的时候最关注几件事:用户名重复怎么判断、初始密码怎么处理、状态和创建人创建时间怎么补全。
用户名重复判断很简单,写一个Mapper方法,根据用户名查询员工数量,如果大于0就抛出业务异常。苍穹外卖项目通常有全局异常处理器,会捕获业务异常这类自定义异常并返回给前端。
初始密码的设置,教学项目通常会用MD5加密。虽然MD5不算安全加密方案,真实项目中更需要BCrypt这类算法,但苍穹外卖项目为了降低学习门槛用了MD5,我这里也保持一致。初始化密码常见的默认值是123456,加密后存入数据库。注意在实际项目中一定要提醒自己换更安全的加密方案,这个后面可以自行扩展。
createTime和updateTime我直接用LocalDateTime.now()填充,createUser和updateUser从ThreadLocal中获取。这里有个关键点要多说一句,day01在做登录功能时往往会把当前登录员工的ID通过JWT解析后存入ThreadLocal,day02所有需要记录操作人的地方都从这个ThreadLocal取。线程使用完一定要记得清理,防止内存泄漏和数据串号。
Service层的核心代码大概长这样:
@Override public void save(EmployeeDTO employeeDTO) { Employee employee = new Employee(); BeanUtils.copyProperties(employeeDTO, employee); Employee existing = employeeMapper.getByUsername(employee.getUsername()); if (existing != null) { throw new BusinessException("用户名已存在"); } 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); }这里使用BeanUtils.copyProperties做属性拷贝,代码简洁,但要注意两个类字段名一致才能拷贝成功。比如DTO里的idNumber对应实体里的idNumber,createTime不会出现在DTO里,所以不会覆盖实体里我们手动设置的值。
2.4 新增员工常见的几个操作陷阱
第一个陷阱是id_number字段在数据库里和Java实体字段名不一致。数据库字段用了下划线风格id_number,Java实体用了驼峰idNumber,如果MyBatis配置没有开启map-underscore-to-camel-case,查询出来的结果就全是null。这个坑我第一天就踩过,结果查出来员工的身份证号怎么都是空。确认一下application.yml里配置了map-underscore-to-camel-case: true,这个问题就解决了。
第二个陷阱是插入后不回显主键。如果后续操作需要用到新员工ID,必须在Mapper的insert标签里配置useGeneratedKeys="true" keyProperty="id",否则插入后实体的id还是null,后面想要拿到ID继续做别的操作就抓瞎了。苍穹外卖的员工模块可能不是很明显,但后面做菜品、套餐插入时这个特别重要。
第三个陷阱是重复点击提交。前端如果没做防抖,用户连续点两次"保存"按钮,同一个用户名就会提交两次,结果第二次被用户名已存在的异常拦住。从后端角度来说这个校验是必须的,但更好的方案是数据库层也建唯一索引,双保险。
3. 员工分页查询:参数、SQL与PageHelper的正确用法
3.1 分页查询的需求和参数设计
员工列表页需要分页展示,因为员工数据多了以后不可能一次性全部加载。前端会传两个核心分页参数:page表示当前页码,pageSize表示每页条数。查询条件是可选的,一般有员工姓名或用户名作为关键字进行模糊搜索。
分页接口通常设计成GET请求,路径是/admin/employee/page,请求参数通过查询字符串传过来。这里需要注意一个问题,接口参数名如果叫page,很多人会下意识地把PageHelper的PageHelper.startPage(page, pageSize)写在Controller里,其实更合理的做法是写在Service层或者直接在Controller调用Service时传参。我更倾向于把分页插件调用放在Controller或Service起始位置,并在查询完成后封装PageResult返回。
分页返回的结果需要包含两个关键部分:total总记录数,records当前页的数据列表。苍穹外卖项目里一般会有一个PageResult类,封装这两个字段,方便前端渲染。我这里直接使用了PageHelper的PageInfo,也可以手工把Page的总数取出来放到PageResult里。
3.2 动态SQL实现条件模糊查询
分页查询往往不是单纯的全表搜索,而是带条件的。比如输入姓名"张"只查姓张的员工,不输入条件就查全部。这就需要Mapper里使用动态SQL。MyBatis的动态SQL语法中<if>标签是最常用的,通过判断参数是否为空来决定是否拼SQL条件。
员工分页查询的Mapper方法大致是这样:
<select id="pageQuery" resultType="com.sky.entity.Employee"> select * from employee <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="username != null and username != ''"> and username like concat('%', #{username}, '%') </if> </where> order by create_time desc </select>这里用<where>标签比直接拼where 1=1优雅得多,而且能自动去掉第一个条件前面多余的and。like查询用concat拼接模糊匹配串,可以防止SQL注入,比直接用'%${name}%'安全得多。order by create_time desc可以让新员工排在前面,实际操作中这个排序很实用,一眼就能看出最近新增了谁。
3.3 PageHelper怎么用才能不出错
PageHelper的使用方法非常简单,在要分页的查询之前调用PageHelper.startPage(page, pageSize),接下来执行的第一个MyBatis查询方法就会自动被分页。需要注意几个容易出错的点。
第一,PageHelper.startPage只对紧接着的下一条查询生效,如果中间又执行了其他MyBatis查询,分页就会加错地方,甚至查出来一堆不相关的数据。所以在Service层要确保startPage后面紧跟着就是employeeMapper.pageQuery,不要穿插别的查询。
第二,page参数和pageSize参数必须校验,reasonable=true虽然能自动处理越界,但如果参数是负数或零,还是会出问题,我的习惯是在Controller层用@Min注解做校验,或者Service里手动判断。
第三,PageHelper在分页后会返回一个Page对象,它本身继承自ArrayList,所以可以直接从getTotal()拿到总记录数,再从Page对象本身拿到列表数据。如果调用查询前没有PageHelper生效,它返回的就是普通的List,此时没有getTotal()方法。
实际代码可以这么写:
@Override public PageResult pageQuery(int page, int pageSize, String name) { PageHelper.startPage(page, pageSize); List<Employee> employeeList = employeeMapper.pageQuery(name); Page<Employee> pageResult = (Page<Employee>) employeeList; long total = pageResult.getTotal(); return new PageResult(total, employeeList); }这里把name作为单独参数传入,和Mapper里的<if>配置对应。注意实体中有些敏感字段,比如身份证号、密码,在返回给前端之前最好脱敏处理,至少不要返回密码。我在做分页时会把密码设为null,或者直接用VO对象隐藏掉,不然接口文档里直接能看到密码的MD5值,会很危险。
4. 员工状态启用与禁用:小功能里的状态管理
4.1 需求场景和实现方案
员工状态管理可以说是整个day02里最"短小精悍"的功能。前端员工列表中会有一个状态开关,点击后可以切换启用和禁用。禁用后员工不能登录系统,这在实际运营中很常见:人员离职、账号被盗、临时停用,都需要禁用员工账号。
这个功能在后端实现并不复杂,通常接口设计为POST /admin/employee/status/{status},路径上带上要修改的状态值,请求参数里带上员工ID。Controller接收两个参数,一个status,一个id,然后调用Service执行update操作。我看到的很多项目会把status放在请求路径上,原因是状态更新一般不需要请求体。
ServiceImpl里的逻辑就是将员工状态更新为前端传过来的status,并同步更新updateTime、updateUser字段。这里有两点要注意:一是校验员工是否存在;二是记录更新人和更新时间。
4.2 更新状态的完整代码演示
Controller层:
@PostMapping("/admin/employee/status/{status}") @ApiOperation("启用禁用员工账号") public Result startOrStop(@PathVariable Integer status, Long id) { employeeService.startOrStop(status, id); return Result.success(); }Service层:
@Override public void startOrStop(Integer status, Long id) { Employee employee = employeeMapper.getById(id); if (employee == null) { throw new BusinessException("员工不存在"); } employee.setStatus(status); employee.setUpdateTime(LocalDateTime.now()); employee.setUpdateUser(BaseContext.getCurrentId()); employeeMapper.update(employee); }Mapper里的update语句我用了动态SQL,这样只更新非空字段,避免把name、phone等字段覆盖成null。因为是先查出完整实体再更新,这种方式比较安全。如果你只传id和status去执行update,SQL里又写了name = #{name},那就会把name置空,很严重。
4.3 状态编码统一与前端展示的衔接
状态字段的值到底用0还是1,是整个项目里需要统一约定的。苍穹外卖项目里通常用1表示启用、0表示禁用,我在代码中建议建一个常量类,比如StatusConstant,里面定义ENABLE=1、DISABLE=0。这样Service层就不会到处出现魔法数字。如果项目中还有其他状态字段,比如订单状态、菜品状态,也统一放在这个常量类或各自的常量类中,方便维护。
前端拿到状态值后,一般用Switch开关显示,1代表开,0代表关。这里前端往往会在切换开关时弹出二次确认框,防止误操作,后端不需要关心这个,只需提供接口即可。但是要注意,在禁用员工时,需要考虑如果当前操作人就是被禁用的员工本人,要不要允许?一般业务上要禁止这种情况,否则创始人不小心把自己禁用了,系统就进不去了。虽然苍穹外卖没有强制实现,但这是一个很好的扩展点。
5. 编辑员工信息:回显与更新的完整链路
5.1 为什么编辑要先回显
编辑员工这个功能从页面操作上看有两个步骤:第一步,点击"编辑"按钮后表单里先出现该员工已有的信息;第二步,修改需要变更的字段后保存。对应的后端接口也有两个:一个是根据ID查询员工详情,一个是更新员工信息。
我先说查询详情接口。很多同学会想,我能不能在编辑保存时直接把整个员工数据提交,不用先查详情?不行,前端需要把当前已有值填充到表单里,不然用户没法知道原来填了什么。而且如果直接通过列表数据回显,列表数据通常已经做过脱敏,比如手机号有可能打码、密码被隐藏,直接回显会导致信息丢失。所以必须提供一个按ID查询详情的接口,返回完整的员工信息(密码除外)。
详情接口的路径一般是GET /admin/employee/{id},Controller接收路径参数ID,Service调用Mapper根据ID查询,返回Employee实体。需要特别注意,返回之前把password字段清空,因为登录密码不能泄露给前端格式。
5.2 更新员工信息的核心逻辑
更新员工的基本逻辑很简单:接收EmployeeDTO,从DTO拷贝属性到实体,设置updateTime和updateUser,执行update操作。
这里有一个非常容易踩的坑:DTO中包含了username字段,如果页面把用户名也允许修改,那就会出现和新增员工一样的重复校验问题。所以编辑员工时的用户名校验逻辑和新增时类似,但要注意排除自己。也就是查询用户名是否被其他员工使用,如果被其他人用了就报"用户名已存在",如果查出来的员工就是当前编辑的这个ID,则允许保存。
另一种做法是在编辑页面禁用用户名输入框,不允许修改用户名。因为用户名是登录账号,通常不应该频繁变动。但即便是禁用了,后端接口也必须能防止恶意调用,不能只依赖前端禁止。所以数据库表里username尽量建唯一索引,后端做排除自身校验。
更新员工时还建议只更新必要字段。如果不注意,更新时把createTime、createUser等字段也一并更新,就会导致数据审计信息丢失。在Mapper的update SQL里,要明确列出允许更新的字段:name、phone、sex、id_number、update_time、update_user,不要出现create_time、create_user的更新。
5.3 更新操作的SQL写法与事务说明
update语句如果是单表更新,MyBatis里我习惯写动态SQL:
<update id="update" parameterType="Employee"> update employee <set> <if test="name != null">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>标签会自动处理末尾的多余逗号,非常方便。因为这里只有一个更新操作,事务并不复杂,但如果以后一个方法里有多次update操作,或者更新员工的同时还要更新其他表,必须在Service方法上加上@Transactional。用一句话理解事务:要么全部成功,要么全部回滚。我在day02阶段就会把@Transactional加到所有写操作上,目的就是养成习惯,后续复杂业务不会漏掉。
6. 本地上传图片:热搜词背后的文件存储方案
6.1 为什么员工功能会牵扯到图片上传
好,聊到现在,你可能会有个疑问:day02的员工基本操作怎么总提到图片上传?原因在于,苍穹外卖项目不仅仅只有员工管理这一个模块,很快你就要开发菜品管理。菜品必须要有展示图片,比如一份宫保鸡丁的封面图片。也就是说,本地上传图片虽然不属于员工表本身的字段,但它是员工操作后台时离不开的一项基础能力,day02把上传功能一并实现,就是为了后续所有需要图片的业务做铺垫。
另外一个原因是,登录后头像、员工头像这类需求在某些扩展版本里也会出现。更重要的是,从学习角度讲,图片上传非常典型:涉及文件IO、磁盘存储、静态资源映射、扩展名校验、大小校验,这些东西都是开发中的必备技能。不管将来用阿里云OSS还是MinIO,本地上传方案都是理解文件上传原理的最短路径。
6.2 本地存储的实现思路:上传接口加静态映射
本地上传图片的思路分三步走。第一,前端通过multipart/form-data方式把文件传给后端,后端用MultipartFile接收;第二,后端把文件保存到本地磁盘的某个目录,比如/usr/local/img/或项目的upload/文件夹,文件名要重新生成避免冲突;第三,把文件的访问URL返回给前端,这样前端就能通过URL直接查看图片了。
在Spring Boot里,将文件保存到本地磁盘,核心代码并不复杂:
public String upload(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String extName = originalFilename.substring(originalFilename.lastIndexOf(".")); String fileName = UUID.randomUUID().toString() + extName; String filePath = basePath + fileName; file.transferTo(new File(filePath)); return "/upload/" + fileName; }这里有两处关键设计。第一,新文件名用UUID.randomUUID()生成,而不是沿用原始文件名,是为了防止两个用户上传同名文件互相覆盖。第二,扩展名是从原始文件名截取的,保存时原文件名丢弃,只保留扩展名。
如果直接把本地磁盘路径返回给前端,比如/usr/local/img/xxx.jpg,那前端是访问不了的。所以我们还需要配置一个静态资源映射,把URL前缀/upload/**映射到磁盘目录。Spring Boot中可以写一个WebMvc配置类:
@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + basePath); } }basePath在application.yml里配置,比如sky.upload.path=/usr/local/img/。这样做之后,前端只需拿到路径/upload/xxx.jpg,浏览器就可以直接显示图片了。
6.3 图片上传的校验与安全保护的几个关键点
图片上传最容易被忽略的就是安全和异常处理。先说文件类型校验。很多人只判断扩展名,比如jpg、png、jpeg。但这远远不够,因为攻击者可以伪造扩展名。更稳妥的判断方式是通过file.getContentType()获取文件的MIME类型,再判断是否以image/开头。同时限制文件大小,Spring Boot中可以通过spring.servlet.multipart.max-file-size和max-request-size来设置,比如单文件最大5MB,总请求最大10MB,这个在application.yml里配置即可。
文件上传的目录路径处理也有讲究。用new File(filePath)保存文件前,要确保目录存在,不然会抛FileNotFoundException。可以在保存前执行File dir = new File(basePath); if (!dir.exists()) { dir.mkdirs(); }。多次上传图片时,如果目录不存在程序直接报错,会造成这个接口看起来"时好时坏"。
上传成功后返回给前端的URL我通常建议返回相对路径,即包含/upload/xxx.jpg。因为如果返回绝对路径,比如http://ip:port/upload/xxx.jpg,以后服务器换端口或域名就会写死,前端存库的数据也要跟着改。相对路径+前端拼接的方式更灵活。
还有一点,本地上传的文件如果不清理,时间长了会占用大量磁盘空间,尤其是开发环境反复上传几十张图片后,磁盘很快就满了。我通常在day02阶段就提醒自己:后续接OSS云存储时,上传逻辑的接口返回值、目录结构、存储路径都要设计成可替换的,所以现在写代码时不要在本地上传逻辑里塞太多业务,比如不要硬编码死目录,而是抽取配置项。
7. 常见问题与排查技巧实录
7.1 员工模块的高频报错和解决方案
我将这几天实际运行中经常遇到的问题整理成一张表,方便大家直接对照排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 新增员工报"用户名已存在"但数据库里查不到 | 事务未提交,脏数据回滚后仍提示;或查询SQL的库不是同一个 | 检查事务配置和连接库地址,确认mysql连接串 |
| 分页查询返回的total始终是0 | 没有使用PageHelper或分页插件未生效;查询前执行了其他Mapper方法 | 确认在分页查询前紧跟PageHelper.startPage |
| 查询员工列表时身份证号为null | 未开启驼峰映射 | 在application.yml设置map-underscore-to-camel-case: true |
| 上传图片后返回的URL浏览器无法访问 | 没有配置静态资源映射或路径不对 | 检查addResourceHandlers配置和basePath是否正确 |
| 上传图片保存时报"FileNotFoundException" | 保存目录不存在 | 保存前使用File.mkdirs()创建目录 |
| 员工禁用后仍然可以登录 | 登录逻辑没有校验status字段 | 在登录Service的查询方法中增加status = 1条件 |
7.2 我排查问题时的思路参考
每次看到报错,不要急着搜错误信息,先看异常堆栈的最高层,确定是Controller、Service还是Mapper抛出来的。如果是BusinessException,通常是业务校验问题,直接看提示信息;如果是DataAccessException,多半是SQL或MyBatis配置问题;如果是FileUploadException,则与上传配置相关。
还有一次印象很深,分页查询接前端参数时,前端传的pageSize是通过字符串拼接的,导致后端接收后类型转换异常。这个问题的本质是前后端参数规范不一致,后来在后端统一用@RequestParam做声明并设置默认值来解决,我强烈建议凡是分页参数都给定默认值,比如@RequestParam(defaultValue = "1") Integer page,这样即使前端漏传也不会崩。
7.3 几个我建议你提前做的优化
员工列表返回数据时,密码字段必须置空。我见过有人直接把select *的结果返回给前端,接口文档里明文出现了MD5密码串,虽然MD5不是原始明文,但也绝对不能暴露。更稳的做法是定义一个EmployeeVO,只返回需要的字段,比如id、username、name、phone、sex、idNumber、status、createTime,避免密码和其他内部字段泄露。
新增员工和编辑员工的参数校验要统一。如果新增时校验了手机号和身份证号格式,编辑时漏掉了,那编辑接口就变成了绕过校验的后门。我的做法是把公共校验规则全部放在DTO字段上,新增和编辑共用一个DTO类,这样天然保证一致性。
本地上传图片还有一个容易被忽视的问题,就是文件名中的特殊字符。如果原始文件名包含中文、空格或特殊符号,直接保存到磁盘可能在某些操作系统上出问题。使用UUID重命名就很好地规避了这个风险。另外,MultipartFile.getOriginalFilename()在某些浏览器下会包含完整路径,所以使用时一定要截取最后一段或只做扩展名提取,不要直接拼到保存路径中。
8. 从day02往后看:这套员工操作还能怎么扩展
员工基本操作做完后,整条CRUD链路已经打通,这套模式几乎可以直接复用到苍穹外卖后续的菜品管理、套餐管理、分类管理上。菜品模块同样是分页查询、新增、修改、启售停售、图片上传,区别只是实体字段更多、关联关系更复杂。所以day02最大的价值不是把员工表增删改查写完,而是把一套标准范式刻在脑子里。
我个人在实际开发中还做了一个小扩展:给员工列表增加多条件组合查询,比如按创建时间范围加姓名加状态来过滤数据。实现方式就是在分页查询的SQL里继续增加<if>条件,同时把查询条件包装成EmployeePageQueryDTO。这个扩展比想象中简单,但我发现很多做这个项目的人一开始都不会想到把DTO用在查询场景,等条件多了之后代码就开始失控。
上传图片功能之后怎么接OSS也很明确:保留Controller层的upload(MultipartFile file)入口,把Service层从"保存本地文件并返回本地路径"换成"调用OSS SDK上传并返回URL",Controller和前端几乎不用改。这也是我建议在设计上传接口时不要让Controller里出现任何磁盘路径的原因。basePath、/upload/**这些细节都应该沉淀在配置和独立的上传Service里,而不是散落在Controller中。
写到这里,你可以把day02当作一条标准公式:新增员工提供POST接口,分页查询提供GET接口,状态修改和编辑提供PUT或POST接口,每条接口都有清晰的DTO参数和统一Result返回。把所有接口用Knife4j测通之后,你再看苍穹外卖后面的章节,会发现一切都变得非常熟悉。操作员工数据和管理菜品数据,本质上就是在同一个框架里换不同的实体再做一遍,框架越熟练,后面的开发越快。