咱们直接进入正题,聊聊苍穹外卖项目里“新增员工”这个功能模块。
我带过不少新手做苍穹外卖这个 Spring Boot 项目,发现大部分人一上来就急着写代码,结果在“新增员工”这种看似基础的功能上反复踩坑。这个功能我觉得是理解整个项目后端开发链路的最佳切入点:它既有参数接收、实体映射,又有密码加密、唯一性校验、公共字段填充,最后还要落到 MyBatis 的 insert 上。换句话说,把新增员工完整跑通一遍,你基本就把一个企业级后端功能从 Controller 到 Mapper 的闭环摸清了。
这篇文章就把我在实现“新增员工”时的设计思路、完整代码、踩过的坑以及排查技巧全部摊开来讲,顺便把热搜里常提到的“本地上传图片”也一并理清。无论你是在跟着视频敲代码,还是准备面试复盘项目,这份内容都能直接拿来参考。
1. 需求再小,也要先把设计理清楚
1.1 新增员工这个功能到底在做什么
苍穹外卖作为一个外卖管理系统,员工指的是门店的管理员、接单员这类内部账号,不是 C 端用户。员工管理模块里,“新增员工”的业务场景是这样:管理员登录后台后,进入员工列表页,点击“新增员工”,填一个表单,包含姓名、账号、手机号、密码,有时候还会有性别、身份证号、头像,然后提交保存。
这个需求听起来简单,但往下拆就复杂了。我一般会让新手先回答三个问题:
- 员工账号需不需要保证唯一?
- 密码在前端还是后端做加密?
- 创建时间、更新时间、创建人、更新人这几个字段谁去填?
这三个问题如果不在写代码前想清楚,后面很容易出现数据脏、接口重复、权限混乱的情况。实际开发中,需求评审阶段就该把这些东西定下来。即便是学习项目,也要养成这个习惯,不然面试的时候一问到一个边界场景你就露怯。
1.2 表结构设计和字段约束说明
苍穹外卖的 employee 表字段是比较经典的,我在项目里用到的核心字段大致如下:
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | bigint | 主键自增 | 员工ID |
| name | varchar(32) | 非空 | 员工姓名 |
| username | varchar(32) | 非空、唯一 | 登录账号 |
| password | varchar(64) | 非空 | 密码,存的是密文 |
| phone | varchar(11) | 非空 | 手机号 |
| sex | varchar(2) | 默认“未知” | 性别 |
| id_number | varchar(18) | 可空 | 身份证号 |
| status | int | 默认1 | 1启用,0禁用 |
| create_time | datetime | 非空 | 创建时间 |
| update_time | datetime | 非空 | 更新时间 |
| create_user | bigint | 非空 | 创建人ID |
| update_user | bigint | 非空 | 更新人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 id="insert" parameterType="Employee" useGeneratedKeys="true" keyProperty="id"> 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>有两点你必须注意:
useGeneratedKeys="true"和keyProperty="id"是配套的,作用是让 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 层 catch
DuplicateKeyException,转换成业务异常提示。 - 中等方案:插入前先加分布式锁,比如基于 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 Result<String> 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 里只需要加一个 addResourceHandlers:
@Configuration 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 自动填充,把唯一的校验拆成数据库约束加异常翻译,再扩展一个头像本地上传的闭环。走完这一轮,你对项目开发的理解会有一个明显的提升。以后不管是继续做苍穹外卖的套餐模块、订单模块,还是去企业里接手真实项目,这种“需求拆解 -> 代码实现 -> 边界补全”的思路都是通用的。