ARTICLE DETAIL

资讯详情

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

苍穹外卖Day02实战总结:从员工管理到本地图片上传完整链路

苍穹外卖Day02实战总结:从员工管理到本地图片上传完整链路 做苍穹外卖项目到Day02刚好卡在从“会调接口”到“会写业务”的分水岭上。第一天把登录、校验、拦截器跑通还属于框架层面的东西第二天开始才是真正碰业务员工管理、分类管理、菜品管理外加那个让不少人卡壳的本地图片上传。这篇总结不按讲义顺序平铺直叙而是按我实际敲代码时“先遇到什么、再踩什么坑、最后怎么想明白”的顺序来梳理。核心场景还是那个热词里反复出现的——苍穹外卖本地上传图片也就是把前端传来的MultipartFile真实存到服务器磁盘上再生成一个可访问的URL地址返回给页面。这一套逻辑不复杂但涉及静态资源映射、文件路径隔离、配置类注册等多个环节任何一个地方断链图片就挂掉。适合刚学完Spring Boot基础、正在冲项目阶段的同学参考也适合准备面试时被人追问“你项目里的文件上传是怎么设计的”的兄弟拿来当答题提纲。1. 从登录到业务Day02的内容全景与表结构设计进Day02之前其实要先想明白一个事为什么会在这个节点开始做“图片上传”。答案在功能场景里。苍穹外卖的管理端要维护菜品菜品必然带图片图片又是分类、口味之外的第三个维度的信息载体。没有图片上传菜品CRUD就只能在数据库里写个死路径没法应付真实运营场景。所以文件上传不是独立的功能点而是为菜品管理服务的支撑能力理解这一点后面做新增菜品接口时思路就顺了。1.1 Day02的功能清单拆解Day02落地的功能模块可以拆成四块员工管理模块新增员工、员工分页查询、启用禁用员工账号、编辑员工信息分类管理模块新增分类、分类分页查询、删除分类、修改分类、启用禁用分类菜品管理模块半截新增菜品、菜品分页查询、根据分类查菜品文件上传模块本地存储方案 静态资源映射四块功能看着多但共性非常明显全部是标准的CRUD只是各自带了业务约束。员工有状态字段所以有启用禁用分类被菜品和套餐引用所以删除前要校验关联菜品带图片和口味所以新增时要处理多表插入。抓住这个共性Day02的代码写起来就是一套模式反复用DTO接收参数、Service处理业务、Mapper操作数据库、VO返回结果。1.2 三张核心表的关系梳理动手写代码前先把表关系看明白。Day02涉及的主要表是employee、category、dish外加一个口味表dish_flavor。employee表是员工表字段不多典型的有id、name、username、password、phone、sex、id_number、status、create_time、update_time、create_user、update_user。注意它里面存的是md5加密后的密码不是明文这就要求新增员工接口里必须做password的MD5处理否则登录模块第一关就挂。category表是分类表核心字段是name、type、sort、status。type字段是关键1表示菜品分类2表示套餐分类这决定了分类管理里新增接口的入参设计。sort字段用于排序实际运营时可以调整菜品展示顺序但Day02只做默认插入先不涉及拖拽排序。dish表是菜品表字段有name、category_id、price、image、description、status。这里的image字段存的就是文件上传后返回的URL地址不是base64字符串更不是本地磁盘路径。很多人偷懒直接把完整磁盘路径存进去一换环境图片就全挂这是个要命的坑。dish_flavor是口味表一个菜品对应多条口味记录结构是外键关联dish_id再加name和value两个字段。新增菜品时必须先插入dish拿到自增主键再拿着这个id去插口味顺序反了就外键报错。1.3 分层架构与请求链路苍穹外卖用的是标准的Controller-Service-Mapper三层结构Controller层收参数、做参数校验Service层写业务逻辑、管事务Mapper层对着表做增删改查。再加上一个common模块放通用类和工具类比如Result、MD5加密工具、JWT工具等。一条完整请求链路长这样前端Vue页面调用axios接口 → 后端Controller接收请求 → 封装DTO传给Service → Service调用Mapper → Mapper执行SQL返回Entity → Service把Entity转成VO → Controller返回Result给前端。Day02我最深的体会是千万不要在Controller里写业务逻辑。新手最容易犯的错就是把密码加密、状态校验全堆在Controller里一份接口调下来Controller几百行Service空荡荡。正确做法是Controller只做“接参数、调Service、返回结果”这三件事业务全丢给Service这样后面加需求、改逻辑时只动Service一个类舒服得多。2. 员工管理模块四个接口背后的业务约束员工管理是Day02第一个完整业务模块功能点看着老套但每一个都藏着实战细节。这一块如果只是照抄代码那和没写一样真正值钱的是理解“为什么这里要这样做”。2.1 新增员工密码加密与用户名唯一校验新增员工的入参是EmployeeDTO包含username、name、phone、sex、idNumber这几个字段。Controller接收后传给ServiceService要做三件事第一判断用户名是否重复。做法是调Mapper的getByUsername方法查库里有没有同名的有了就抛异常。这里要注意一个细节异常类型最好自定义一个业务异常类而不是直接用RuntimeException。苍穹外卖项目里一般会建一个BusinessException或者复用全局异常处理里的自定义异常这样前端能拿到具体的错误提示“用户名已存在”而不是笼统的500。第二对密码做MD5加密。默认密码一般是123456存的时候不能明文入库用DigestUtils.md5DigestAsHex(password.getBytes())转成密文再set进去。这个点面试常问得能说清楚MD5是不可逆的所以校验密码时是拿输入的密码再加密一次和库里比对而不是解密库里的密文。第三补全公共字段。谁创建的、什么时候创建的这些字段在新增时就要填好。Day02这里还可以手工写setCreateTime这些但强烈建议直接上公共字段自动填充后面会拆开讲。实操里最容易翻车的点是Long类型id的精度丢失问题。employee表的id是bigint自增主键数据库里是19位数字但Java的Long虽然能存JSON序列化返回给前端JS时JS的Number精度只有2^5319位数字会丢精度。前端拿到的id最后几位直接变成0后面做编辑回显时永远对不上。解决办法是在id字段上加JsonSerialize(using ToStringSerializer.class)把所有Long类型的id转成字符串返回。2.2 员工分页查询PageHelper插件与VO返回分页查询的入参是EmployeePageQueryDTO包含page、pageSize、name三个字段。实现用的是PageHelper插件代码模式固定在Service里先new一个Page对象然后调Mapper的pageQuery方法再new PageResult封装total和records返回。这段代码本身没什么难度但有两个细节值得说。第一个细节是返回给前端的对象里不能有密码。很多新手直接返回Employee实体等于把md5密文都暴露给前端了虽然密文不至于被直接破解但这是糟糕的接口设计。正确的做法是转成EmployeeVO只返回id、username、name、phone、sex、idNumber、status、createTime这些字段。第二个细节是查询条件name的模糊查询写法。Mapper里用动态SQL形如 and name like concat(%, #{name}, %) 注意用concat避免字符串拼接的SQL注入风险虽然mybatis的#{}已经防注入但concat是更稳妥的写法。第二个细节是分页参数要校验。page和pageSize前端可能传0或者负数PageHelper直接用会抛异常。在Controller层或者Service层加个简单的判断非法参数直接抛参数异常别让异常打到数据库层去。2.3 启用禁用员工账号与编辑回显启用禁用接口接收两个参数一个员工的id一个status状态值。Controller层的写法是PathVariable(id) Long id和RequestParam(status) Integer status注意PathVariable和RequestParam别搞混。Service层就是简单的一条update语句把status改掉。这里有个设计上的小坑权限问题。员工账号不应该允许员工自己禁用自己否则就会出现“最后一个管理员把自己禁用了”的尴尬。如果后面做权限系统这里要加判断但Day02阶段可以先通过前端控制后端也最好加上防止操作当前登录用户的校验。编辑员工接口分两步先是回显根据id查出员工信息返回给前端填充表单再是提交接收EmployeeDTO更新数据。回显直接按id查同样要转成VO把密码字段抹掉。提交更新时要注意密码字段如果前端没传就不能用默认值覆盖否则编辑一下密码就被重置了。用动态SQL的标签非空字段才更新。2.4 公共字段自动填充AOP切面设计的核心价值Day02后半段有一块内容含金量很高就是公共字段自动填充。create_time、update_time、create_user、update_user这四个字段无论是员工管理、分类管理还是菜品管理做insert或update时都要手动set代码重复量巨大而且容易漏。解决思路是自定义一个AutoFill注解加在Mapper的insert和update方法上再写一个切面类AutoFillAspect通过AOP拦截这些方法在方法执行前利用反射把四个公共字段设进去。具体实现分三步切入点注解用within和annotation的与关系限定了切面只作用于Mapper接口上加了AutoFill注解的方法。切面里获取到当前操作类型INSERT还是UPDATE后如果是INSERT就set四个字段如果是UPDATE就set两个update字段。当前登录用户的id怎么拿可以从ThreadLocal里取苍穹外卖里登录信息就是存在ThreadLocal的BaseContext里的。这里的重点是反射操作有点绕但逻辑值得吃透。拿MethodSignature拿到参数循环参数列表找到类型匹配的实体对象再通过getDeclaredField拿到对应属性setAccessible(true)后set值。这几个操作缺一个就报错或者静默失效。我实操时第一次跑通后脑子里就一句话AOP不是炫技是把重复劳动从代码里拔出去。后面新增任何带公共字段的表只要Mapper方法上加注解切面自动处理爽得不行。3. 分类管理CRUD里的边界条件与关联校验分类管理比员工管理简单功能就是菜品的分类和套餐分类的统一管理。但这里有个Day02必须清楚的点分类表是被菜品表和套餐表引用的所以它的删除操作不是一个单纯的delete而是要先检查“有没有菜引用这个分类”。3.1 新增分类与分页查询新增分类的入参是CategoryDTO字段有name、type、sort。type只有1菜品分类和2套餐分类两个值Service层要做枚举校验直接写死判断就行不是1也不是2就抛参数异常。sort是排序号做展示顺序用的Day02阶段前端传什么存什么就行。分页查询和员工分页长得几乎一样区别是多了type的查询条件。可以实现CategoryPageQueryDTO带上name和type两个过滤条件Mapper动态SQL判断然后PageHelper照旧。这一块代码基本是照猫画虎能独立写出来就说明员工管理那块的模式已经吃透了。3.2 删除分类的关联校验与状态约束删除分类是分类管理里最容易翻车的接口。表面逻辑是id传过来直接delete但真要这么写菜品那边就会出现一堆“指向不存在分类”的脏数据前端列表加载直接报错。正确处理逻辑是删除前先查菜品表里有几条记录的category_id等于要删除的id。数字大于0就抛异常提示“当前分类下关联了菜品无法删除”等于0才真正执行delete。套餐表的逻辑同理虽然Day02还没做套餐管理但接口最好一并判断。另一个容易忽略的点是分类状态为启用的不允许直接删除。这是个很现实的业务规则启用的分类意味着正在被运营使用删了会出线上事故。Day02可以先用status做个判断如果status为1就抛异常提示先停用再删除。3.3 修改分类与启用禁用修改分类的入参是CategoryDTO带id、name、sort等字段Service层直接updateById同样注意非空字段才更新的问题。启用禁用就是改status逻辑和员工启用禁用完全一样连Controller层的注解写法都能复用。这个模块做完之后其实能总结出一条分类管理的通用套路带关联表的主数据删除前一定要做“被引用检查”。这不仅是苍穹外卖项目里的要求以后做任何管理后台只要出现主数据-子数据的关联这条规则都适用。4. 本地上传图片从MultipartFile到URL的完整链路终于写到热词核心了。苍穹外卖本地上传图片这个功能表面上是“把图片存到服务器”但前后端链路其实非常长前端发请求 → Controller接文件 → 校验后缀和大小 → 生成存储路径 → 写入磁盘 → 返回URL → 配置静态资源映射 → 前端拿到URL回显图片。任何一步断了图片就传不成或者显示不了。下面按实际代码顺序逐段拆。4.1 前端请求与后端接收MultipartFile的参数绑定前端Vue页面里的上传组件用的是element-ui的el-upload通过action属性指向后端接口地址name属性指定文件字段名。在苍穹外卖项目里请求路径一般是/admin/common/upload文件字段名叫file。后端Controller的接收逻辑就一句话参数类型用MultipartFile参数名必须和前端name一致。PostMapping(/upload) public ResultString upload(MultipartFile file) { // 业务逻辑 }这里注意不能加RequestBody文件上传走的是multipart/form-data格式Spring MVC会用MultipartResolver自动解析不需要手动处理。一个必须关注的配置是文件上传大小限制。Spring Boot默认单文件最大1MB请求总大小最大10MB实际菜品图片随便一张就是几MB不改配置直接给你报“Maximum upload size exceeded”。要么在application.yml里调大要么在WebMvc配置类里做MultipartConfigElement自定义。Day02本地开发阶段建议yml里配置spring: servlet: multipart: max-file-size: 10MB max-request-size: 10MB4.2 文件后缀校验白名单验证不能省拿到MultipartFile后第一件事不是存盘是校验。校验分两层后缀名和大小。后缀校验用原始文件名substring拿到扩展名再和允许的列表比对。外卖项目里的图片常见后缀就那么几个jpg、jpeg、png、bmp、gif、webp其余一律拒绝。String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.)); if (!Arrays.asList(.jpg, .jpeg, .png, .bmp, .gif, .webp).contains(extension.toLowerCase())) { throw new BusinessException(文件格式不正确); }注意一定做toLowerCase上传一个.JPG大写后缀的文件不转小写直接contains判断会无辜被拒。文件大小校验更直接Spring Boot的multipart配置已经控制了大文件但业务层最好还是主动判断一下。一般菜品图片不超过5MB比较合理可以if (file.getSize() 5 * 1024 * 1024)直接抛异常。4.3 本地存储路径与文件名生成避免文件覆盖和乱码校验通过后就是生成存储路径。这是整个本地图片上传方案里我第一次踩坑的地方花了一下午才彻底想明白。一个常见错误是直接把前端传来的原始文件名拿来存盘。这会带来两个问题第一中文名或带特殊字符的文件名会造成乱码第二如果两个人上传了同名的文件后上传的会覆盖前者。所以正确的做法是给每个上传文件重新生成一个唯一的文件名传统方案是UUID拼接后缀String fileName UUID.randomUUID().toString() extension;如果需要按日期分目录存储防止一个目录下文件过多可以在存储路径里拼上日期String datePath DateTimeFormatter.ofPattern(yyyy-MM-dd).format(LocalDateTime.now()); String dirPath basePath datePath /; File file new File(dirPath); if (!file.exists()) { file.mkdirs(); }存储目录的basePath要单独抽出来配置不要硬编码在代码里。在application.yml里加一个自定义配置项比如sky.jiajia.upload-path然后在配置类里用Value注入。sky: upload-path: /home/img/Linux部署时路径末尾必须带/否则拼接的filePath会变成/home/img2024-10-01这种错误格式这个细节很多人漏掉。Windows本地开发时路径分隔符可以用File.separator或者直接写/Java的File类都能识别。然后就是真正写入磁盘file.transferTo(new File(filePath));这里要注意File对象只能new不能提前创建文件再transferTo重复创建会报FileAlreadyExistsException。同时要保证父目录已经mkdirs过否则同样报NoSuchFileException。4.4 静态资源映射为什么图片URL访问不到文件已经存到磁盘上了但这时候前端即使拿到path也访问不到因为Spring Boot默认只能访问classpath下的静态资源磁盘路径不在它的管辖范围。所以必须在WebMvcConfigurer里加一个自定义映射把URL前缀映射到本地目录这部分是本地图片上传能不能回显的关键。Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Value(${sky.upload-path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/image/**) .addResourceLocations(file: uploadPath); } }这段代码的意思是当浏览器请求“/image/2024-10-01/xxx.jpg”时Spring会去“file:/home/img/2024-10-01/xxx.jpg”找这个文件。注意file:前缀不能少少了她就会去classpath里找资源永远404。路径字符串还有个大坑addResourceLocations里的路径必须以“/”结尾否则映射会失效。我调试那会儿就是漏了这个尾斜杠前端图片一直403查了半天才发现是路径末尾少了个斜杠整个人直接崩溃后来把这个规则写死在注释里记牢了。做完这个配置Controller层返回的URL才是完整的String url /image/ datePath / fileName;返回给前端之后前端拼上域名和后端端口就能直接访问显示图片。如果你要用nginx做反向代理这部分可以对外暴露成静态目录处理但项目里先不搞保持简单。4.5 图片回显失败的三大元凶本地图片上传做完我整理了一下图片回显失败最常见的三个原因按出现频率排序环境变量路径写错。Windows开发环境习惯写D:/upload/但Linux服务器部署时路径不存在或者没有写权限图片就存不进去。我建议开发环境用相对路径或者独立的临时目录部署时再把上传路径改成运维指定的绝对路径。静态资源映射漏配。addResourceHandlers方法没注册或者前缀对不上前端拿着URL请求什么都不返回。检查方法一是直接浏览器访问返回的URL看看是404还是200404基本都是映射问题500多是磁盘异常。文件权限问题。Linux上启动进程的用户对upload目录没有写权限transferTo会抛IOException。这个是最阴间的代码和配置全对就是权限不够需要chmod或者让运维给目录授权。5. 菜品管理新增菜品与本地图片的联动场景前面做图片上传到这里才真正用起来。新增菜品接口是Day02的收尾大戏它要把分类、图片、口味三块能力串起来也是第一次涉及多表事务的接口。5.1 新增菜品的入参设计DishDTO和DishFlavorDTO新增菜品的入参比前面所有接口都复杂因为菜品本身的数据结构是“一道菜 多种口味”。DishDTO里除了name、categoryId、price、image、description这些基础字段还有一个List flavors每个口味又含name和value。比如“辣度”这个口味的name是“辣度”value可能是“不辣,微辣,中辣,特辣”前端用逗号分隔的字符串传。这里有个接口设计的经验值不要为了图省事把口味直接拼接成一个字符串存菜品表里这样后面做套餐、做购物车选项时根本没法拆。宁可多一张表也要规范化设计。5.2 新增菜品的实现逻辑先插入主表再插入口味表Service层逻辑分四步顺序不能乱。第一步检查分类是否存在且启用。categoryId传过来先查分类表分类不存在或者已经停用直接抛异常避免产生脏数据。第二步插入菜品主表。把DishDTO的属性拷贝到Dish实体status默认设为1起售createTime、updateTime这些公共字段交给自动填充切面处理。这里有一个关键点insert后要拿到自增id。如果Mapper用Options(useGeneratedKeys true, keyProperty id)注解mybatis执行完会自动把自增id回填到实体的id属性里这是后面插入口味表的依据。第三步插入口味表。遍历flavors先把每个DishFlavorDTO转成DishFlavor实体set里头的DishId就是刚才回填的id然后批量插入。为什么用批量而不是循环单条一方面是效率另一方面是保证事务一致性要么全成功要么全失败。第四步整体事务。因为涉及两张表Service方法上必须加Transactional注解确保主表和口味表要么同时插入成功要么同时回滚绝不能出现“菜品插入成功但口味没了”的中间状态。5.3 根据分类查询菜品下拉框数据从哪来新增菜品页面里有个分类下拉框前端要拉取分类列表。这个接口就是根据type去查分类表往回传的是一组Category对象。整体逻辑和分类分页里的查询条件一致不展开细说。真正值得注意的是菜品新增页面用到的图片上传接口加的就是我们前面做的本地图片上传。前端上传成功后拿到URL把URL存到addForm里提交新增菜品时一并传给后端。这样图片URL才和菜品数据绑到一起上架后运营端和C端展示的都是这个URL。本地图片上传真正发挥价值是在菜品管理这个场景里脱离业务纯做文件上传意义会小很多。5.4 菜品分页查询关联分类名称的VO组装菜品分页查询比员工分页多一步菜品表里存的categoryId是数字前端列表要显示分类名称不能把数字直接展现。实现方案有两种一种是SQL里JOIN分类表查名称一种是在Java代码里循环查分类表拼名称。项目里一般用SQL JOIN的方式select idpageQuery resultTypecom.sky.vo.DishVO select d.*, c.name as categoryName from dish d left join category c on d.category_id c.id where if testname ! null and name ! and d.name like concat(%, #{name}, %) /if if testcategoryId ! null and d.category_id #{categoryId} /if /where order by d.create_time desc /select这种场景正是VO的意义所在。DishVO比Dish多了个categoryName字段专门用于页面展示。跑通这个之后我算是彻底理解了Entity、DTO、VO三者的区别Entity对应表结构DTO对应入参VO对应出参各司其职。6. 踩坑实录与调试心得项目写了多少天坑就踩了多少个Day02里面尤其密集。这里按我实际遇到问题的先后顺序把几个最有价值的坑单独列出来顺手给出排查思路方便你对照问题定位。6.1 图片上传后浏览器访问404这个坑我印象最深。上传接口返回的URL单独拿出来在浏览器里直接访问返回404页面上的图片区域一片空白。排查路径是这样的先确认文件确实写进磁盘了然后看URL拼接的路径和磁盘路径是否一致再看配置类里addResourceHandlers是否生效。最后发现是addResourceLocations(file: uploadPath)里uploadPath从yml读取后末尾没有斜杠导致整个映射直接失效。排查效率上有一个小技巧在配置类里加一行System.out.println(uploadPath)或者直接打断点看实际值是多少。日志往往比人肉比对代码更快暴露问题。6.2 MultipartFile为空或参数名对不上前端上传组件配置的name是“file”Controller里参数名写的也是“file”原则上不会有问题。但如果你用了Spring MVC的RequestParam注解指定名称或者前端上传组件里name属性和Controller参数名不一致file就会是null。这种问题没什么好说的唯一的方法就是仔细检查两端参数名是否完全一致。前端Vue代码和Java后端代码来回翻一旦发现名字不一样改了立马好。6.3 新增菜品口味插入失败这个坑出现在dish_flavor批量插入。我先用List 去调Mapper的insertBatch方法结果每一条flavor都没有拿到dishId因为前面主表insert后没有回填id。这里需要明确的是Options(useGeneratedKeys true, keyProperty id)这个注解是加在主表insert方法上的回填的对象也是主表实体。如果你在Service层只复制属性到Dish实体时用的是新对象而insert操作的是另一个对象那id永远回填不到你需要的那个实体上。代码写着写着回填的是dao里的对象你后续set dishId用的是service里自己new的对象NullPointerException直接砸脸。我推荐的做法是Service层始终只持有一个Dish对象insert前后都用它这样回填必然生效。6.4 启用禁用接口报Required request parameter missing启动禁用接口的Controller写法是PathVariable(id) Long id和RequestParam(status) Integer status分开接收。如果你把status也放在路径里或者请求方式从GET改成POST但前端没跟上就会报缺参数。Restful风格接口调试最直接的办法是用Swagger或者Postman先手动测一遍确认参数传递方式正常后再打开前端联调。前后端参数传递方式不一致的问题大多数通过这种手动测试能提前发现。6.5 自动填充切面不生效公共字段自动填充的切面类写好后突然发现insert和update操作没有自动set公共字段排查一圈发现是切面切入点表达式写错了。我用的切入点是within注解匹配类级别加上annotation注解匹配方法级别两者是与关系。如果Mapper接口上只标了AutoFill而方法上没标或者切面类没加Aspect和Component统统不生效。这三个条件缺一不可检查顺序建议是先确认启动类能扫描到切面包再确认注解打在了接口和方法的正确位置最后确认动态SQL里的字段名和实体属性名对应得上。7. 一点实用收尾建议Day02整体的知识密度其实比Day01大很多如果是一边看视频一边敲代码很容易出现“视频看懂了代码跑不起来”的情况。我后来的习惯是每看完一个功能模块先不要急着跟敲而是把讲义里的接口设计思路自己写一遍伪代码流程再对照源码逐步叠代码。比如新增菜品先写“检查分类→插入菜品→回填主键→插入口味→事务包裹”有了流程再写细节效率高很多。另一个建议是把本地图片上传的调试经验单独记一份笔记。存盘路径怎么配、URL怎么拼、静态资源映射怎么写、权限和大小限制怎么调整这些内容是以后做任何管理系统都会用到的底层能力值得花时间彻底吃透。做完Day02苍穹外卖的管理端主流程已经能串起来了登录、管员工、管分类、传图片、加菜品。下一步做套餐管理时你会发现大部分逻辑都是对已有模块的复用和扩展那种“好像在哪里见过”的感觉就是技能体系开始成形的信号。
返回列表