ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot新增员工功能全解析:从DTO校验到MyBatis落库

Spring Boot新增员工功能全解析:从DTO校验到MyBatis落库 咱们直接进入正题聊聊苍穹外卖项目里“新增员工”这个功能模块。我带过不少新手做苍穹外卖这个 Spring Boot 项目发现大部分人一上来就急着写代码结果在“新增员工”这种看似基础的功能上反复踩坑。这个功能我觉得是理解整个项目后端开发链路的最佳切入点它既有参数接收、实体映射又有密码加密、唯一性校验、公共字段填充最后还要落到 MyBatis 的 insert 上。换句话说把新增员工完整跑通一遍你基本就把一个企业级后端功能从 Controller 到 Mapper 的闭环摸清了。这篇文章就把我在实现“新增员工”时的设计思路、完整代码、踩过的坑以及排查技巧全部摊开来讲顺便把热搜里常提到的“本地上传图片”也一并理清。无论你是在跟着视频敲代码还是准备面试复盘项目这份内容都能直接拿来参考。1. 需求再小也要先把设计理清楚1.1 新增员工这个功能到底在做什么苍穹外卖作为一个外卖管理系统员工指的是门店的管理员、接单员这类内部账号不是 C 端用户。员工管理模块里“新增员工”的业务场景是这样管理员登录后台后进入员工列表页点击“新增员工”填一个表单包含姓名、账号、手机号、密码有时候还会有性别、身份证号、头像然后提交保存。这个需求听起来简单但往下拆就复杂了。我一般会让新手先回答三个问题员工账号需不需要保证唯一密码在前端还是后端做加密创建时间、更新时间、创建人、更新人这几个字段谁去填这三个问题如果不在写代码前想清楚后面很容易出现数据脏、接口重复、权限混乱的情况。实际开发中需求评审阶段就该把这些东西定下来。即便是学习项目也要养成这个习惯不然面试的时候一问到一个边界场景你就露怯。1.2 表结构设计和字段约束说明苍穹外卖的 employee 表字段是比较经典的我在项目里用到的核心字段大致如下字段名类型约束说明idbigint主键自增员工IDnamevarchar(32)非空员工姓名usernamevarchar(32)非空、唯一登录账号passwordvarchar(64)非空密码存的是密文phonevarchar(11)非空手机号sexvarchar(2)默认“未知”性别id_numbervarchar(18)可空身份证号statusint默认11启用0禁用create_timedatetime非空创建时间update_timedatetime非空更新时间create_userbigint非空创建人IDupdate_userbigint非空更新人ID注意 password 字段长度我之前见过有人把 varchar 设成 32结果 MD5 加密后的 32 位十六进制字符串刚刚够用但如果后面想升级成加盐 BCrypt长度就憋屈了。建议在初始化表的时候就给到 64给自己留条后路。username 要建唯一索引这是防止账号重复的第一道防线。但这里有个细节程序里的唯一性校验和数据库的唯一索引不是互相替代的关系而是双保险。你程序里查了一遍觉得没问题最后 insert 的时候照样可能撞上并发反过来只靠数据库索引前台反馈就不友好。所以规范做法是两层都做。2. 从 Controller 到 Mapper一条完整的写入链路2.1 Controller 层不要直接把 Entity 暴露给前端很多新手喜欢偷懒直接让 Controller 接收 Employee 实体前端传什么字段就映射什么字段。这么做在新增员工时问题还不算大但隐患很明显Entity 里如果有 status、createTime 这种后端才应该控制的字段前端一旦传了恶意值就会被直接写入数据库。苍穹外卖项目的规范做法是用 DTO 接收参数。我在代码里是这样定义的Data public class EmployeeDTO implements Serializable { private Long id; NotBlank(message 姓名不能为空) private String name; NotBlank(message 账号不能为空) private String username; NotBlank(message 手机号不能为空) Pattern(regexp ^1[3-9]\\d{9}$, message 手机号格式不正确) private String phone; private String sex; private String idNumber; NotBlank(message 密码不能为空) private String password; }注意两个关键点用NotBlank校验字符串参数不要用NotNull。因为前端如果传一个空字符串NotNull拦不住到数据库层才发现非空约束返回的异常就不好看了。手机号用Pattern正则做格式校验避免把乱七八糟的值存进去。这里正则表达式^1[3-9]\\d{9}$是国内的常见手机号规则虽然已经够用但我得提醒你这只是一个基础校验真正严格的短信验证码校验通常不在这一层做。Controller 里的写法就清晰了接收 DTO调用 Service返回统一结果对象RestController RequestMapping(/admin/employee) public class EmployeeController { Autowired private EmployeeService employeeService; PostMapping public Result save(RequestBody EmployeeDTO employeeDTO) { log.info(新增员工{}, employeeDTO.getUsername()); employeeService.save(employeeDTO); return Result.success(); } }你可能会注意到这里的 Result 是苍穹外卖项目封装好的统一返回结果把 code、msg、data 都包起来了。这是规范化接口设计的一个很好范例前端可以根据 code 判断请求是否成功不用靠解析异常文本。2.2 Service 层加密、校验、填充一个都不能少Service 层是这个功能的“业务心脏”。苍穹外卖项目里它的任务可以拆成四步校验用户名是否已存在对密码进行加密设置员工状态默认值为 1填充创建时间、更新时间、创建人、更新人然后插入数据库。我写出来的核心逻辑是下面这个样子你细看注释每一步都是有原因的Service public class EmployeeServiceImpl implements EmployeeService { Autowired private EmployeeMapper employeeMapper; Override public void save(EmployeeDTO employeeDTO) { // 1. 校验用户名唯一性 Employee employee employeeMapper.getByUsername(employeeDTO.getUsername()); if (employee ! null) { throw new RuntimeException(账号已存在); } // 2. 实体转换DTO 转 Entity Employee emp new Employee(); BeanUtils.copyProperties(employeeDTO, emp); // 3. 密码加密这里用的是 Spring 自带的 MD5 工具 emp.setPassword(DigestUtils.md5DigestAsHex(employeeDTO.getPassword().getBytes())); // 4. 状态默认启用 emp.setStatus(StatusConstant.ENABLE); // 5. 公共字段填充 emp.setCreateTime(LocalDateTime.now()); emp.setUpdateTime(LocalDateTime.now()); emp.setCreateUser(BaseContext.getCurrentId()); emp.setUpdateUser(BaseContext.getCurrentId()); // 6. 插入 employeeMapper.insert(emp); } }我特别强调一下BaseContext.getCurrentId()。苍穹外卖项目用了 ThreadLocal 来保存当前登录用户的 ID这个值是在拦截器里从 JWT Token 解析出来放进去的。这样 Service 层不需要把“当前操作人”作为参数层层传递代码会清爽很多。但是坑也在这如果请求没有经过拦截器BaseContext 里就是空的取出来是 null。所以在拦截器里要记得在请求处理完之后清理 ThreadLocal不然线程池复用线程时会串号。这个我在后面排查问题时会再展开。BeanUtils.copyProperties 这个工具可以帮你完成同名属性拷贝省去一堆 setter 调用。但它有个隐蔽问题它会忽略类型不一致的字段而且如果字段名对不上也不会报错。所以我建议拷贝完之后回头看一眼 Employee 实体里的字段名跟 DTO 是否严格对应。比如 DTO 里叫idNumber实体里就不能叫id_number否则拷不过去。2.3 Mapper 层XML 里写 insert别忘了主键回填苍穹外卖项目的持久层用的 MyBatis但这里有个新手容易忽略的问题关联查询和动态 SQL 写在 XML 里而简单 insert 也要统一放进去。不要把 SQL 散落在注解里后期维护会疯掉。我在 EmployeeMapper.java 里声明方法public interface EmployeeMapper { Employee getByUsername(String username); void insert(Employee employee); }对应的 XML 文件如下insert idinsert parameterTypeEmployee useGeneratedKeystrue keyPropertyid INSERT INTO employee (name, username, password, phone, sex, id_number, status, create_time, update_time, create_user, update_user) VALUES (#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{status}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}) /insert有两点你必须注意useGeneratedKeystrue和keyPropertyid是配套的作用是让 MySQL 自增主键回填到传入的 Employee 对象的 id 字段上。如果你不加这个做完 insert 之后实体的 id 还是 null后面想拿新员工的 ID 去关联其他表就无从下手。SQL 语句里只写 Employee 实体里TableField或驼峰映射能对应上的列名。如果你在数据库里用的是create_user这种下划线风格需要在 mybatis 配置里开启驼峰映射map-underscore-to-camel-case: true。开启后createUser才能自动映射到create_user列。苍穹外卖项目的配置文件里通常默认开启但有些人自己手搭环境时会漏掉。Mapper 这个链路走通了新增员工的数据就能真正落库。不过很多初学者走到这里就以为完事了其实上面的代码只是“能跑”离“好用”还差几步。下面我把最容易出问题的几个细节单独拿出来拷问一遍。3. 这个功能里最容易出事的 4 个细节3.1 密码必须加密固定盐方案也比你想象中普遍有些教程为了省事密码直接明文存库。我完全不建议这么做哪怕只是学习项目也要养成数据安全的习惯。苍穹外卖项目里用的是一行很简单的加密代码emp.setPassword(DigestUtils.md5DigestAsHex(employeeDTO.getPassword().getBytes()));这是 Spring 自带的 MD5 加密工具类输出 32 位十六进制字符串。你需要知道它的致命弱点对同一个明文每次加密结果都一样。也就是说如果两个员工密码都设置为 admin123那密文完全相同黑客拿到一次明文比对就能把所有相同密码的账号打穿。所以更稳妥的做法是在密码里拼接一个随机盐String salt UUID.randomUUID().toString().replace(-, ); String encrypted DigestUtils.md5DigestAsHex((salt password).getBytes());然后把 salt 也存到数据库里校验时再拿同一个盐拼一次。但在苍穹外卖项目里密码字段就一个没有盐字段所以我建议学习阶段你可以扩展一张表或者直接把盐拼进密文存比如salt$md5value校验时按$切分即可。另外BCrypt 是一个更现代的替代方案Spring Security 里的BCryptPasswordEncoder天生带随机盐每次加密结果都不同安全性远高于 MD5。但在苍穹外卖这种非安全框架项目里直接用 Spring 的 DigestUtils 是合理的毕竟学习重心不在这里。你只要在面试时说出“项目里用了 MD5但我了解它存在彩虹表风险生产上应该换 BCrypt”就已经能甩开大部分竞争者了。3.2 账号唯一性校验的并发问题我在前面写的校验逻辑是先getByUsername查一遍查不到再 insert。这种“先查后插”的模式在单线程下没问题但并发情况下会出漏子。设想一个场景管理员连续点了两次“保存”或者两个管理员同时创建同名账号。两次请求都执行了getByUsername都发现账号不存在于是都往数据库里插。最终结果就是两条 username 完全一样的记录。唯一的兜底是数据库唯一索引——如果建了唯一索引第二次 insert 会抛DuplicateKeyException但程序没能把这种异常翻译成“账号已存在”的友好提示前端就会看到一个 500 报错。解决并发问题有三个层次最省事数据库唯一索引兜底然后在 Service 层 catchDuplicateKeyException转换成业务异常提示。中等方案插入前先加分布式锁比如基于 Redis 的setnx保证同一时刻只有一个请求在检查同名账号。高可靠方案锁和唯一索引同时上前端再防重复点击。苍穹外卖这种单体项目用第 1 种就够了。我在实际开发里遇到这一类问题开场白通常是“我先查后插只是第一版生产环境铁定会被并发打穿。”这句话能让别人知道你不是只会照抄教程。3.3 公共字段自动填充别在 Service 里手动 set很多初学版本里createTime、updateTime、createUser、updateUser 这 4 个字段是在 Service 里手动 set 的我在上面 2.2 节的代码就是这么写的。手动 set 的问题在于项目里十几个业务方法都要重复这一堆赋值代码你没法保证所有人都记得调 BaseContext。更好的做法是用 MyBatis 的公共字段自动填充。步骤是在 Employee 实体的 createTime、updateTime 等字段上加TableField(fill FieldFill.INSERT)或TableField(fill FieldFill.INSERT_UPDATE)。这个注解是 MyBatis-Plus 的如果你用的是原生 MyBatis就得通过拦截器实现。写一个 MetaObjectHandler 的实现类在 insertFill 和 updateFill 里统一赋值。以 MyBatis-Plus 为例代码长这样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()); } }这样所有表、所有新增操作都自动带上时间和人不用每个 Service 方法里再 set 一遍。如果你照着手动 set 的方式做也能运行只是后期维护成本会往上翻。我的建议是第一次敲代码可以用手动 set 先理清逻辑理解了之后再重构为自动填充这样既懂了原理也学会了更优的做法。3.4 状态字段默认值的两种实现status 字段表示员工账号是否启用新增员工时默认应该是启用状态。这里有两种实现思路我记得很多读者在这里会困惑第一种在 Service 层显式设置emp.setStatus(1)代码意图明确看代码的人一眼就知道新增的员工默认启用。第二种数据库字段设置DEFAULT 1程序里不传 status落库自动为 1。我推荐第一种。原因是数据库默认值适合兜底但程序里如果不显式设置后续代码一旦有人删掉了这个赋值数据库就把默认值写进去了排查问题时你可能完全察觉不到。而且显式赋值让单元测试更好写——你可以在断言里直接验证状态是否为 1不用真的去查数据库。顺带说一句status 这个字段我还见过有人直接用字符串类型存“启用”和“禁用”这个千万别学。状态字段应该用数字或枚举语义化的字符串只在展示层做映射数据库里存数字效率更高也方便扩展。4. 顺带聊聊头像上传本地上传图片的那点事4.1 为什么新增员工会关联图片上传很多做苍穹外卖的同学会在新增员工页面看到头像上传的入口。项目里有个通用文件上传接口配合本地上传图片的需求特别常见。这里我明确一下新增员工的核心表字段本身可以不包含头像但如果你把头像从员工表里拆出来或者想在列表里展示头像就必须先搞定图片上传和回显。“本地上传图片”这个词我猜你大概率是在折腾“怎么把前端上传的文件保存到服务器”时搜到的。我遇到过太多人一听到上传就联想到阿里云 OSS其实苍穹外卖项目里最简单、最适合学习的方案是上传到本地磁盘再通过静态资源映射把图片 URL 暴露出去。4.2 本地上传图片的具体实现步骤完整流程可以拆成四步接收文件、保存到本地目录、生成可访问 URL、把 URL 存进数据库。第一步Controller 里用 MultipartFile 接收文件PostMapping(/upload) public ResultString upload(MultipartFile file) { String originalFilename file.getOriginalFilename(); String extName originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID() extName; // 保存到本地 file.transferTo(new File(basePath fileName)); return Result.success(/images/ fileName); }注意这里我做了两件重要的事文件名用 UUID 重新生成防止用户上传../../etc/passwd这种路径穿越文件名也避免同名覆盖。后缀名截取原文件后缀保证图片能正常预览。但你需要校验后缀白名单只允许 jpg、png、gif、webp 等常见格式防止有人上传 exe 或者 html。第二步在 WebMvc 配置类里把本地目录映射成 URL 访问。Spring Boot 里只需要加一个 addResourceHandlersConfiguration public class WebMvcConfiguration implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/images/**) .addResourceLocations(file: basePath); } }这里有个非常容易踩的坑addResourceLocations必须写成file:开头因为你要映射的是本地文件系统路径。如果你漏了file:前缀Spring 会把它当成 classpath 资源目录那图片永远 404。第三步前端用返回的 URL 拼接成完整的图片地址比如http://localhost:8080/images/xxx.png回显时直接放到 img 标签的 src 里就行。你在新增员工表单提交时把头像 URL 作为一个字段传给后端再存进 employee 表的 avatar 字段如果扩展了的话整条链路就通了。4.3 静态资源映射和 URL 拼接的坑我在 4.2 里讲的方案虽然能用但有两个坑相当隐蔽。第一个坑是 Linux 和 Windows 路径分隔符不同。Windows 上你写D:/upload/没问题Linux 上file:/upload/开头也行但如果你用了File.separator来拼接路径不同环境下路径风格不一致可能导致图片访问不了。稳妥做法是统一用正斜杠/拼接Java 在 Windows 上也能识别。第二个坑是网关和端口。苍穹外卖项目如果是前后端分离前端通过 Nginx 或网关转发那么你返回的 URL 不能写死localhost:8080。正确做法是返回相对路径比如/images/xxx.png让前端根据当前环境拼域名。我看到有同学直接把绝对地址写死进数据库结果换一台机器访问所有头像全挂这就是典型的经验问题。本地上传图片在开发环境非常方便但生产环境一旦多节点部署文件只存在某个节点上负载均衡转发到另一个节点就找不到图了。这种场景下应该用 OSS、MinIO 之类的对象存储。我在苍穹外卖项目里通常跟读者说先用本地存储搞懂原理再替换成 MinIO代码结构上把文件存储封装成一个接口后续替换实现类即可。5. 常见问题与排查技巧实录5.1 新增成功但列表查不到数据这个问题在我带新手时出现频率相当高。现象是接口返回成功数据库里也有数据但前端员工列表就是看不到新记录。排查思路分三步走先看列表查询接口用的表名是不是 employee别你说的是员工查的是另一张 user 表。再看列表查询有没有加WHERE status 1之类的过滤条件如果你新增时 status 设了 0列表默认只查启用自然看不到。最后看分页参数。如果是 MyBatis 分页插件 PageHelper注意要先设置分页再执行 SQL顺序反了会导致分页不生效或者查出来的数据计数不对。我遇到过最离谱的情况是新增接口在写入时把status字段的值覆盖成了 null数据库默认值没有生效列表查询的WHERE status 1把这条数据过滤掉了。报表上看就是“数据神秘失踪”。5.2 登录时密码校验失败新增员工功能做完紧接着就是写登录密码校验失败是非常常见的连锁问题。通常的原因是新增时加密了密码但登录查询时没有加密拿明文去和密文比新增时用 MD5 加密登录时用别的加密算法加密前没有统一 trim前端多传了个空格密文就变了。我建议你在做登录接口之前先写一个简单的单元测试或直接用数据库工具验证加密链路。比如把DigestUtils.md5DigestAsHex(123456.getBytes())的结果存下来再用同样的输入跑一遍登录加密逻辑看密文是否一致。这样能快速排除八成的问题。5.3 insert 报 SQL 异常但抓不到具体原因MyBatis 报错时如果只看了控制台前几行经常找不到真正原因。比如常见的BindException是参数绑定失败检查实体字段有没有写错SQLSyntaxErrorException是 SQL 语法问题DataIntegrityViolationException是约束冲突。我的习惯是在 service 方法的入口打日志同时把 XML 里的 SQL 完整输出到控制台。application.yml 里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样 MyBatis 会打印实际执行的预编译 SQL 和参数列表。你别嫌日志多排查问题比猜快多了。看到 SQL 之后先复制到数据库客户端手动执行一遍看是不是参数类型不匹配或者字段名错误。绝大多数 insert 失败的问题都能用这个办法定位到具体字段而不是一遍遍脑补。5.4 常见问题速查表我把日常答疑里和新增员工相关的高频问题整理成了表格方便你碰到问题时直接对着查。现象可能原因解决思路新增接口返回 500日志提示“账号已存在”程序里的唯一性校验抛了运行时异常在全局异常处理器中捕获转换为友好提示用户名重复还能插入成功唯一索引没建或创建时 SQL 写错给 username 添加唯一索引BaseContext.getCurrentId() 返回 null请求没经过 JWT 拦截器或 ThreadLocal 被提前清理检查拦截器注册路径确认/admin/employee在拦截范围数据库时间字段为 null实体里没有设置 createTime使用公共字段自动填充或手动 set手机号能存进乱七八糟的值缺少 Pattern 校验Controller DTO 加正则校验图片上传成功但访问 404静态资源映射忘了写 file: 前缀检查 addResourceLocations 写的是不是file:开头头像 URL 换环境就访问不了把绝对地址写死在数据库改为存相对路径由前端拼完整域名这张表是我一年里答疑的浓缩。你会发现所有问题其实都能追到同一个根因对数据字段的生命周期没有想清楚或者对请求到数据库的链路少了某一环。只要你能按“请求进来 - 参数校验 - 业务处理 - SQL 落库 - 数据出来”这个流程去定位就没有查不出来的问题。5.5 补充一个 ThreadLocal 的串号问题前面说 BaseContext 是 ThreadLocal这里再多说一句。Tomcat 默认使用线程池线程处理完一个请求后并不会销毁而是归还到池里等下一个请求使用。如果请求结束时你没有把 ThreadLocal 里的用户 ID 移除那么下一个被这个线程处理的请求可能拿到上一个登录用户的 ID。这个 bug 的可怕之处在于它不是必然出现的而是偶发。新增一条员工数据创建人有时是 A有时是 B看起来像灵异事件。解决办法是注册一个拦截器或过滤器在afterCompletion里调用BaseContext.remove()。我每次写 ThreadLocal 相关代码都会把这行清理代码放在 try-finally 的 finally 里确保即使业务代码抛异常也不会留下脏数据。最后再分享一点我个人的经验苍穹外卖的“新增员工”表面上是个 CRUD但它覆盖的知识点非常密集DTO 设计、参数校验、密码加密、唯一性约束、公共字段填充、MyBatis 主键回填、异常处理乃至 ThreadLocal 的使用与清理。我想说如果这个功能你能不查资料独立写完并且能解释清楚每一步为什么这么写那你的 Spring Boot 后端基本功就已经超过大多数初学者了。我个人建议你把实现方式记下来之后再自己动手重构一遍把手动 set 公共字段改成 MP 自动填充把唯一的校验拆成数据库约束加异常翻译再扩展一个头像本地上传的闭环。走完这一轮你对项目开发的理解会有一个明显的提升。以后不管是继续做苍穹外卖的套餐模块、订单模块还是去企业里接手真实项目这种“需求拆解 - 代码实现 - 边界补全”的思路都是通用的。
返回列表