
1. 第三天开工前的状态盘点进度、坑和今天的目标做苍穹外卖这种全栈项目最怕的不是代码写不出来而是写着写着把自己写迷糊了——不知道昨天干了什么、今天该干什么、整个项目还剩什么。第三天一早我先把前两天的进度摊开梳理了一遍再决定今天主攻哪一块。前两天完成了基础工程搭建和用户端的一部分功能。具体来说Spring Boot 项目骨架、MyBatis 基础整合、通用返回结果类这些底层的东西都弄妥了管理端的员工登录、退出、以及员工管理的基础增删改查已经跑通。这里的“跑通”指的是接口层面能用 Postman 调通页面真正点起来还没完全走完。数据库表结构是照着标准的外卖项目设计来的employee、category、dish、setmeal、order、user、address_book、shopping_cart 这些核心表。坦白说第二天晚上卡在了文件上传上。不是不会写上传接口而是对“本地存储”和“云存储”这两条路线有点犹豫。很多教程直接让你接入阿里云 OSS但我觉得作为本地开发调试阶段先用本地磁盘存储反而更轻、更好排查问题。这个决定在后来的调试中省了不少事。今天给自己定的目标很简单三件事把图片本地上传功能完整落地前端能显示、后端能保存、文件路径能正确回显。完成分类管理Category的功能闭环——新增、分页查询、删除、修改状态。顺手把公共字段自动填充这个老生常谈的问题做了因为在写分类和后续的菜品功能时created_time、updated_time 这种字段一个个手动 set 实在太蠢了。一上午干下来整体节奏比预想中顺利但中间也踩了几个值得记录的坑路径分隔符、文件重名、RequestBody 和 MultipartFile 混用时的参数接收问题。这些细节单个拿出来都不难但串在一起的时候确实会让人脑壳疼。下面挨个说。2. 图片本地上传最接地气的文件存储方案2.1 为什么先做本地存储而不是直接上云很多小伙伴一上来就照着生产环境的方案做OSS、七牛云、又拍云一顿接结果本地调试的时候还得配 Bucket、配 AccessKey数据发到公网不说万一 Key 泄露就是麻烦事。我个人的判断是项目开发的前半段尤其是功能还在频繁改的阶段本地磁盘存储是最快的路径。原因有三点不依赖外网环境断网也能调试。文件直接在项目目录或指定目录下出了问题打开文件夹就能看排查成本极低。后续切 OSS 时只需要把存储层抽成接口上传逻辑改一行实现类即可并不需要伤筋动骨。苍穹外卖这种项目图片无非就是菜品图、套餐图、分类图标这几类。把存储路径设计成按日期分目录既有一定的规则性又方便后续对接云存储时做生命周期管理。在 Spring Boot 项目里做本地存储核心其实就是两个点拿到原始文件名生成不重复的新文件名把 MultipartFile 的内容写到磁盘的指定位置。至于校验文件类型、限制文件大小属于锦上添花但建议开工第一天就加上省得后面别人传了个 .jsp 上来心里发慌。2.2 文件上传的完整实现过程先说说我最终落地的代码结构。我新建了一个FileController专门处理上传和回显RestController RequestMapping(/admin/file) Slf4j public class FileController { Autowired private FileStorageService fileStorageService; PostMapping(/upload) public ResultString upload(MultipartFile file) { if (file null || file.isEmpty()) { return Result.error(请选择要上传的文件); } // 校验原始文件名 String originalFilename file.getOriginalFilename(); if (!isAllowedImage(originalFilename)) { return Result.error(仅支持上传 jpg、png、jpeg、gif、webp 格式的图片); } String url fileStorageService.upload(file); return Result.success(url); } }注意这里的参数是MultipartFile file前面没有RequestBody也没有RequestParam。只要前端是用multipart/form-data格式提交的Spring MVC 就会自动绑定到 Controller 方法的参数上这一点对刚接触文件上传的人是个常见的困惑点。FileStorageService的本地实现如下Service public class LocalFileStorageServiceImpl implements FileStorageService { Value(${file.upload-path}) private String uploadPath; Override public String upload(MultipartFile file) { // 1. 生成唯一文件名 String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString().replace(-, ) ext; // 2. 按日期分子目录 String dateDir new SimpleDateFormat(yyyyMMdd).format(new Date()); File dir new File(uploadPath File.separator dateDir); if (!dir.exists()) { dir.mkdirs(); } // 3. 保存文件 File dest new File(dir, newFileName); try { file.transferTo(dest); } catch (IOException e) { log.error(文件保存失败, e); throw new RuntimeException(文件保存失败); } // 4. 返回访问路径 return / dateDir / newFileName; } }这段代码里最值得说的是文件名重命名。如果直接使用originalFilename存盘会有两个隐患一是中文文件名或特殊字符可能在某些环境下导致乱码或写入失败二是两个用户同时上传同一个photo.jpg后上传的会把先上传的覆盖掉。用 UUID 不仅全局唯一还没有规律性别人也猜不到你的图片真实路径对安全也是一层微弱的保护。虽然 UUID 文件名不如业务名直观但在外卖这种场景下你根本不需要通过文件名去识别图片——数据库里存的是路径业务表里关联的是路径原始文件名反而是无用的包袱。文件类型校验我也简单做了一下private boolean isAllowedImage(String filename) { String ext filename.substring(filename.lastIndexOf(.) 1).toLowerCase(); return Arrays.asList(jpg, jpeg, png, gif, webp).contains(ext); }注意这种“看后缀”的校验只是第一道防线。如果要更严谨应该去读文件头Magic Number不过对学习项目来说后缀校验 大小限制已经能挡住绝大多数误操作。大小限制我放在了配置文件里用 Spring 内置的 multipart 配置即可spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB2.3 静态资源映射上传之后还得能访问文件写到磁盘只是第一步关键是要让浏览器能通过 URL 访问到它。在 Spring Boot 中默认的静态资源路径是classpath:/static/等位置你存到本地磁盘的目录并不在其中。所以必须加一个资源映射把我的/upload/虚拟路径映射到实际文件目录。这一步不配置的话前端 img 标签的 src 指向http://localhost:8080/upload/20260612/xxx.jpg就会返回 404而且你在浏览器里直接访问这个路径也打不开。初学者最容易在这里卡壳因为后端明明返回了路径前端就是显示不出图片。配置方式有两种。第一种是写一个 WebMvc 配置类Configuration public class WebMvcConfig implements WebMvcConfigurer { Value(${file.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }第二种是直接在 application.yml 里配spring: web: resources: static-locations: file:${file.upload-path}/我推荐第一种因为它的映射关系更明确而且后面如果还需要拦截器放行某些特殊路径可以顺手在这个配置类里一起处理。注意addResourceLocations的写法必须是file:前缀加绝对路径且目录末尾要带/否则映射会失效。file:./upload/是相对路径写法意思是以项目运行目录为基准我实际测试时也常用相对路径因为便于部署和寻找但如果你在 IDE 里改了工作目录相对路径就会飘所以生产环境还是建议用绝对路径。苍穹外卖是学习型项目怎么方便怎么来但最好在配置里单独抽一个file.upload-path变量而不是把路径写死在代码里。2.4 本地上传过程中踩到的三个坑坑一路径分隔符。我一开始写的是uploadPath / dateDir在 Windows 上运行没问题因为 Windows 的磁盘路径能兼容正斜杠。但把这个代码放到 Linux 服务器上字符串拼接路径的方式依然可以工作因为 Java 的 File 类在解析路径时会自动处理正斜杠。真正的坑在于你手工拼接字符串给浏览器访问时不要用File.separator因为 Windows 下它是反斜杠\拼出来的 URL 是\upload\20260612\xxx.jpg浏览器解析这个 URL 百分百出错。所以记住一句话磁盘路径可以用 File.separatorURL 路径一律用正斜杠。我在本地存储实现里构造 File 对象时无所谓但返回 URL 时手动拼的是/dateDir/newFileName。坑二UUID 文件名虽然唯一但丢了扩展名就麻烦了。我见过有人图方便直接用UUID.randomUUID()作为新文件名不保留扩展名结果图片是能打开但前端通过contentType判断类型时可能出问题而且以后排查问题时分不清到底是 jpg 还是 png。保留扩展名这个习惯建议从一开始就养成。坑三文件被占用导致删除失败。Windows 上做本地调试时如果你用图片查看器打开了刚上传的图片再用代码去删除或覆盖这个文件会报“文件正在被另一进程使用”。这不是代码问题是 Windows 的文件锁机制。解决办法是调试阶段别用系统看图器预览上传目录要看图片直接用浏览器访问 URL。这个坑在 Linux 上没有但也提醒我一点删除文件的接口要捕获异常不能让它直接抛给前端。3. 分类管理功能闭环新增、分页、删除、启停状态3.1 分类表设计里容易被忽略的一个字段苍穹外卖的分类表设计得很有代表性它用一个 type 字段区分“菜品分类”和“套餐分类”。CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, type TINYINT COMMENT 类型 1:菜品分类 2:套餐分类, name VARCHAR(32) NOT NULL COMMENT 分类名称, sort INT NOT NULL DEFAULT 0 COMMENT 排序字段, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态 0:禁用 1:启用, create_time DATETIME, update_time DATETIME, create_user BIGINT, update_user BIGINT );这个设计在日常开发中很常见一张表通过枚举字段区分多种业务类型省得建两张几乎一样的表。写代码时查询接口就必须根据 type 做条件过滤。我在写 CategoryController 时Service 层的查询方法接受一个Integer type参数type 为空则查全部否则按类型过滤这也是通用做法。另外注意sort字段。有的同学会在后端写死排序规则比如ORDER BY sort ASC或者ORDER BY create_time DESC。我建议排序规则做成可配置或至少明确约定新增分类时默认 sort 值要合理查询时先按 sort 升序再按 create_time 升序保证同 sort 值时先创建的排在前面。这个细节用户不一定感知得到但分页列表一旦数据多了排序不稳定会让分页查询出现重复或漏数据。3.2 新增分类的完整链路新增分类时前端会传过来一个 JSON 对象包含 type、name、sort。这里有两个问题要提前想清楚name 要不要做唯一性校验同一个类型下不能有重名分类但不同 type 之间可以重名。比如可以有一个菜品分类叫“饮品”也可以有一个套餐分类叫“饮品”。所以校验 SQL 的 where 条件要带上 type。create_time、create_user 这些字段由谁赋值我选择在这一步引入“公共字段自动填充”后面单独讲。Service 层的实现逻辑public void save(CategoryDTO categoryDTO) { Category category new Category(); BeanUtils.copyProperties(categoryDTO, category); category.setStatus(1); // 新增默认启用 category.setCreateTime(LocalDateTime.now()); category.setUpdateTime(LocalDateTime.now()); category.setCreateUser(BaseContext.getCurrentId()); category.setUpdateUser(BaseContext.getCurrentId()); categoryMapper.insert(category); }关于状态字段注意一点新增分类默认是启用还是禁用我的选择是默认启用。因为对于后台管理人员来说新增一个分类通常是马上要用的如果默认禁用还得多一步启用操作徒增工作量。但也有人倾向默认禁用宁缺毋滥。这不是技术问题是产品逻辑问题写项目文档时可以把这个决策写上去面试时也算一个可聊的点。3.3 分页查询参数传递和 SQL 写法分页查询我把 page、pageSize、name、type 四个参数都接上了。name 是模糊查询type 是精确查询两个条件同时存在时用 AND 连接有一条为空就不拼这个条件。这里我直接用了 MyBatis 的 PageHelper 插件。很多教程还在手写 LIMIT 计算 offsetPageHelper 的优势在于用起来极其省事而且和 PageInfo 封装类配合返回值里带上 total 和 pages前端直接可用。Service 层代码public PageResult page(int page, int pageSize, String name, Integer type) { PageHelper.startPage(page, pageSize); ListCategory list categoryMapper.list(name, type); PageInfoCategory pageInfo new PageInfo(list); return new PageResult(pageInfo.getTotal(), pageInfo.getList()); }写 Mapper 的 XML 时有一个查询条件的经典写法select idlist resultTypecom.sky.entity.Category SELECT * FROM category where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testtype ! null AND type #{type} /if /where ORDER BY sort ASC, create_time ASC /select注意 LIKE 拼接这里不要写成%${name}%这种字符串拼接会有 SQL 注入风险用 CONCAT 函数配合 #{} 占位符才是安全写法。虽然这个项目是学习项目但写安全的 SQL 应该是一开始就刻在骨子里的习惯。3.4 删除分类时为什么报“被引用”错误分类删除是今天第二个坑的来源。我在测试时新增了一个分类然后往里面加了一个菜品再去删除这个分类时前端直接提示“删除失败”。排查下来原因并不复杂菜品表和套餐关系表里存在外键引用删了分类就破坏了数据的完整性。处理方案有两种一种是物理删除前做关联检测查询 dish 表里 category_id 等于当前分类 ID 的记录数大于 0 就拒绝删除并返回明确提示。另一种是逻辑删除给 category 表加一个 deleted 字段删除时只改标记。逻辑删除的好处是数据可恢复坏处是所有查询都要记得带上deleted 0条件容易漏。苍穹外卖这类项目我更倾向物理删除 关联检测因为业务规模没那么大真删了也就删了没必要保留一堆逻辑垃圾。但如果做真实生产项目尤其是有订单历史数据的系统逻辑删除往往更稳妥。两种方案各有利弊面试时能说清各自适用场景比掌握某个固定写法更值钱。关联检测的 Service 代码public void deleteById(Long id) { // 检查是否有菜品关联此分类 Integer count dishMapper.countByCategoryId(id); if (count 0) { throw new BusinessException(该分类下存在菜品无法删除); } categoryMapper.deleteById(id); }这里的 BusinessException 配合全局异常处理器返回 Result.error(该分类下存在菜品无法删除)前端拿到 message 直接弹框体验就很好。3.5 启用禁用分类update 时只更新该更新的字段分类的启用禁用接口很简单改一下 status 就行。但这里存在一个很小的坑如果你用 MyBatis 的 update 语句不带if判断直接把整个对象的所有字段更新一遍那 name、sort 这些字段就必须在调用前确认已查出来否则就把别人置空了。更稳的做法是写一个只更新 status、update_time、update_user 的专用 update 语句。update idupdateStatus UPDATE category SET status #{status}, update_time #{updateTime}, update_user #{updateUser} WHERE id #{id} /update同理分类的修改接口也应该用if动态更新只更新非空字段。这样不仅安全还省流量更重要的是避免了你以为“我只改了一个字段”结果把其他字段也覆盖成了 null 的悲惨事故。4. 公共字段自动填充MyBatis 的 MetaObjectHandler 实战4.1 每张表都写一遍 create_time 的愚蠢写到分类管理时我明显感觉到重复劳动了create_time、update_time、create_user、update_user这四个字段每张表都要赋值。如果每个 Service 方法里都写一遍setCreateTime(LocalDateTime.now())代码丑不说还容易漏。苍穹外卖的管理端表几乎都包含这四个公共字段所以做一个公共字段自动填充是值得的。这样后续写菜品、套餐、订单功能时就再也不用手动 set 这四个字段了。MyBatis-Plus 里有现成的MetaObjectHandler但苍穹外卖的 MyBatis 并没有用 Plus而是我们自己定义注解 拦截器来实现或者换一种思路直接用 MyBatis 的拦截器统一处理。由于项目里没有引入 MyBatis-Plus我用了一种更轻量的方案自定义注解AutoFill配合 MyBatis 的 Interceptor在 SQL 执行前自动填充公共字段。这种做法的核心价值在于业务代码完全不用关心公共字段拦截器统一搞定而且可以区分 insert 和 update 两种操作。4.2 自动填充的完整实现第一步定义注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AutoFill { // 操作类型INSERT、UPDATE OperationType value(); }第二步写拦截器Slf4j public class AutoFillInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { Method method invocation.getMethod(); AutoFill autoFill method.getAnnotation(AutoFill.class); if (autoFill null) { return invocation.proceed(); } OperationType operationType autoFill.value(); // 获取方法参数 Object[] args invocation.getArgs(); if (args null || args.length 0) { return invocation.proceed(); } Object entity args[0]; Class? entityClass entity.getClass(); LocalDateTime now LocalDateTime.now(); Long currentId BaseContext.getCurrentId(); if (operationType OperationType.INSERT) { Field createTime entityClass.getDeclaredField(createTime); createTime.setAccessible(true); createTime.set(entity, now); // 其他字段同理 } else if (operationType OperationType.UPDATE) { Field updateTime entityClass.getDeclaredField(updateTime); updateTime.setAccessible(true); updateTime.set(entity, now); // updateUser 同理 } return invocation.proceed(); } }第三步在 Mapper 接口方法上打上注解。实际做的时候我用反射的方式给四个字段统一赋值。反射在这个场景下完全够用因为拦截器本身就是 MyBatis 框架级的扩展点用反射再正常不过。唯一要注意的是如果实体类里没有对应字段反射会报 NoSuchFieldException所以我做了一个字段存在性判断可以先拿到类的 Field 列表遍历赋值而不是硬编码写死四个字段名。这套方案虽然比 MyBatis-Plus 的 MetaObjectHandler 代码量多一点但能让新手把 MyBatis 的拦截器机制彻底搞清楚。面试时聊这个比只会调 Plus API 要有亮点得多。不过如果你用的是 MyBatis-Plus直接继承 MetaObjectHandler 会更省事建议根据项目实际情况挑选。4.3 自动填充别忘了 BaseContext 这个线程级缓存公共字段里的 create_user 和 update_user 从哪来从当前登录用户的 ID 来。我之前在登录功能时就做了一个 ThreadLocal 工具类 BaseContext登录成功后把用户 ID 放进去在需要的地方取出来。这也是苍穹外卖项目里一个很重要的知识点。ThreadLocal 在每个线程里有独立的副本同一线程的任何地方都能取到当前用户信息。但要注意请求结束后一定要在线程池里被复用之前调用 remove 清理否则在高并发下可能会出现“用户 A 的请求用户 B 看到”的串号问题。尽量在拦截器的 afterCompletion 阶段统一清理比在每个业务方法里手动清理更稳妥。我把这个 BaseContext 设计成了静态方法用起来像这样Long currentId BaseContext.getCurrentId();这里有一个小坑要提前预防如果这个接口是不需要登录就能访问的比如某些公共查询接口那 currentId 可能为 null。自动填充时如果直接 set 一个 null 进去数据库字段是 BIGINT 且非空的话会直接报 SQL 异常。所以更稳妥的做法是自动填充统一设置 create_user 和 update_user 时先判断 BaseContext 里有没有值没有就默认 0 或者干脆不填。我在自动填充拦截器里加了一个判断Long userId BaseContext.getCurrentId(); if (userId ! null) { // 填充用户字段 }这样既保证了插入不报错也保留了清晰的兜底逻辑。5. 今天实现过程中最值得复盘的两个小问题5.1 JSON 对象和表单参数同时传Controller 怎么写在改分类管理时有个交互是“上传分类图标 提交分类信息”。前端想一次请求把图片和 JSON 数据一起发过来。如果我接口写的是CategoryDTO categoryDTO用RequestBody接收同时又想接收 MultipartFile就会发生参数解析问题RequestBody只能反序列化一个 JSON body和 multipart 数据格式是冲突的。解决方案有两种一是前端把分类信息里的字段也平铺到 FormData 里后端接口用RequestParam一个个接收或者用一个普通的 VO 对象接收。这种方式代码看起来笨重但很直接前端拼 FormData 也不难。二是前端先把图片上传到文件服务拿到 URL 后再随 JSON 一起提交分类数据。分类表里存的是图片 URL 字符串而不是文件本身。这个方案更干净我最后还是选了这种方式因为它把文件上传和业务提交解耦了后续换云存储、加图片裁剪功能都更方便。很多真实项目的做法就是这种“先传图、后提数据”的模式前端页面上也是分两步操作只是用户无感知而已。所以分类管理的表单里图片 URL 就是一个普通的字符串字段提交时带上一个已经上传好的 URL。5.2 Result 返回类的设计success 和 error 别乱用写到这里我想提一个更基础但很多人忽略的问题统一返回结果的封装。我自封装了 Result 类大约是这样Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.code 1; result.msg 操作成功; result.data data; return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.code 0; result.msg msg; return result; } }这里我特意选了“1 成功0 失败”的 code 约定而不是 HTTP 状态码那一套。HTTP 状态码关心的是网络层面的成功与否业务层面的成败需要业务码来承载。比如你删除一个不存在的分类HTTP 状态是 200但业务上它是失败的前端需要根据业务码去提示用户。这个区分在做前后端分离项目时特别重要别把 200 和“成功”划等号。还有一个经验是错误信息要写得人能看懂。删除失败和该分类下存在菜品无法删除给用户的感受完全不一样。排查问题时详细错误信息也能省去很多沟通成本。6. 明日计划和一个调试小技巧今天的分类管理和上传功能算是把管理端的基础数据链路打通了。我个人对明天的安排是菜品管理。菜品这块有一个比较绕的点是口味属性一个菜品可以有多组口味每组口味下面又有多个选项比如“辣度”组里有“不辣、微辣、中辣、特辣”这些数据该怎么存、怎么传、怎么回显。苍穹外卖里通常是用一个字符串把 JSON 结构直接存到 dish 表的 flavors 字段里查询时再解析出来这种方法在数据量不大时极其省事但需要保证 JSON 格式的一致性。有个小建议想分享给刚开始做这类后台管理项目的朋友当你觉得某个功能接口特别绕、前后端联调老出问题时先抓包看真实请求和响应再对照数据库里落的数据而不是盯着代码一行行猜。我今天的分类分页查询一开始在页面上老是多出几条数据后来看了 SQL 日志发现是 PageHelper 的 page 和 pageSize 没接好page 从 1 开始但 PageHelper 也按 1 处理逻辑本身没问题反而是前端传参时把 pageSize 传成了字符串MyBatis 自动做了类型转换没报错数据才看着不对劲。这种问题不抓包很难定位。还有一个小技巧把 MyBatis 的 SQL 日志打开在开发阶段能救命的。配置只需要一行logging: level: com.sky.mapper: debug这样每个查询的 SQL 和参数都会打到控制台排查“查出来的数据不对”“多了少了条件”这类问题基本看两眼日志就能定位。等你对这个项目的 SQL 足够熟悉了再关掉也不迟。第三天的进度整体符合预期。核心收获不光是功能跑通了而是对本地文件存储的完整链路、动态 SQL 的边界处理、以及 MyBatis 拦截器的扩展方式都有了更实际的理解。项目就是这样一天推进一点点每一步都踏踏实实调试过累积到后面你会突然发现自己已经能独立解决很多问题了。