ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

苍穹外卖Day01核心解析:工程结构、JWT认证与本地图片上传实践

苍穹外卖Day01核心解析:工程结构、JWT认证与本地图片上传实践 1. 先讲清楚苍穹外卖这个项目到底在做什么Day01最容易掉进去的认知陷阱我第一次接触苍穹外卖Day01的资料包时第一反应是这不就是个普通的外卖管理系统吗照着视频敲一遍代码把页面跑起来就算会了。真正动手之后才发现如果一开始就用这种心态后面几乎每个环节都会踩坑——不是代码敲不出来而是你根本不知道自己在敲什么。苍穹外卖本身是一个完整的外卖业务系统分为服务端后端接口、管理端给门店管理员用的Web后台和用户端微信小程序点餐三块。你平时用的美团、饿了么核心链路无非是用户在小程序浏览菜品 → 加入购物车 → 提交订单 → 商家接单出餐 → 系统完成结算。苍穹外卖就是把这个完整链路从零实现一遍而且用的是目前国内Java后端开发的主流技术栈Spring Boot MyBatis MySQL Redis JWT WebSocket前端是Vue和小程序。Day01在整个项目中扮演的角色是地基中的地基——它不涉及复杂的业务逻辑而是要把整个项目的骨架立起来。具体包括三件事搭建后端工程结构、初始化数据库表结构、把管理端登录跑通包含JWT令牌的签发与校验。如果只看到登录功能那就真的浪费了Day01的价值。Day01真正要解决的是一个团队在开发这种多人协作项目时怎么约定工程结构、接口规范、异常处理、认证机制让后边二十多天的开发不打架。这也是我写这篇笔记的初衷。网上关于苍穹外卖的笔记很多但大多数是视频内容的复述比如先建数据库再启动项目然后录入代码。我要写的是另外一种角度每个关键操作背后是什么考虑哪些坑是视频里没拍到的以及本地上传图片这个功能在Day01中为什么值得你多花半小时研究——它可不是上传个图片这么简单。2. 环境准备与工程初始化版本选错哭都来不及2.1 开发环境不要凭感觉选直接按这套版本走苍穹外卖的技术栈版本是有讲究的。我在实际搭建过程中发现如果你用的是JDK 17甚至更高版本而课程资料基于JDK 8或11编写你可能会在Spring Boot启动时遇到各种莫名其妙的兼容问题。Day01建议的环境版本如下组件推荐版本说明JDK1.8 或 11项目基于Spring Boot 2.7.xJDK 8/11完全够用Maven3.6.3 以上负责依赖管理版本不要太新MySQL5.7 或 8.08.0需要指定驱动类名5.7用默认即可Spring Boot2.7.x这是课程选的版本自带内置TomcatNode.js14.x 以上前端项目构建用实际Day01主要跑后端Nginx1.20 以上反向代理管理端和用户端的前端静态资源我踩过的第一个坑就是MySQL驱动。用MySQL 8.0时配置文件里的driver-class-name写成com.mysql.jdbc.Driver会启动报错必须改成com.mysql.cj.jdbc.Driver。而且连接串里最好加上serverTimezoneAsia/Shanghai不然你往数据库里写入当前时间时会发现跟本地时间差了8个小时。2.2 后端工程为什么拆成三个模块一看就懂的分层设计苍穹外卖的Day01会直接给你一个父工程sky-take-out下面拆了三个子模块sky-common、sky-pojo、sky-server。很多第一次接触多模块项目的同学会问不就一个Spring Boot工程吗拆这么碎干嘛这是有实际原因的。sky-common放的是通用工具类和公共配置比如统一返回结果Result、异常处理类、JWT工具类、常量类sky-pojo放的是实体类、DTO、VO——就是各种数据载体sky-server才是真正的业务代码Controller、Service、Mapper都在这。拆开之后负责写sky-common的人只需要关注通用能力写业务的人不关心工具怎么实现。Day01开始就把这个结构定下来后面开发菜品、订单模块时直接往sky-server里加包就行。如果以后你自己要接一个从零开始的项目我也建议用这个思路先把公共模块独立出来。不要把所有代码堆在一个工程里否则后续每次改实体都要牵连一堆模块重新打包很痛苦。2.3 数据库初始化别只导入脚本要搞懂这几种表的关系Day01资料包里通常会带一个sky.sql或sky_take_out.sql脚本直接导入就能拿到全部表结构。但我强烈建议你导入之后花时间把表过一遍至少知道每个模块的存储主键设计——因为后面你在写SQL时大概率需要关联查询。核心表有这么几类员工表employee存管理员账号字段包括id、name、username、password、phone、sex、id_number、status启用/停用、create_time、update_time、create_user、update_user。注意密码不是明文是MD5加密后的密文。分类表category菜品分类和套餐分类都在这张表用type字段区分1为菜品分类2为套餐分类另一个关键字段是sort排序号。菜品表dish、口味表dish_flavor菜品的基本信息在dish多口味属性辣度、规格在dish_flavor属于一对多的关系。套餐表setmeal、套餐菜品关联表setmeal_dish套餐与菜品是多对多通过setmeal_dish关联。用户表user、地址表address_book、购物车表shopping_cart、订单表orders、订单明细表order_detail用户端的核心数据。Day01主要用到的是employee表——管理端登录就是查这张表。但你如果只盯着employee后面做分类管理、菜品管理时会一脸懵。我的建议是趁Day01的空闲把每张表的主外键关系梳理一遍画个关系草图存下来。这比多敲两遍代码有价值得多。2.4 配置文件里的两个容易忽略的点Day01工程的application.yml会有两个application.yml是主配置application-dev.yml是开发环境配置。Spring Boot启动时通过spring.profiles.active: dev来激活dev环境。为什么要这样分因为数据库地址、Redis地址这些在开发环境和生产环境是不一样硬写在一个文件里换环境就得改文件容易改错。还有一个细节是MyBatis的map-underscore-to-camel-case配置。数据库字段是create_time这种下划线命名Java实体类是createTime这种驼峰命名必须开启自动映射mybatis: configuration: map-underscore-to-camel-case: true不开这个配置的后果是你查询employee表时create_time字段无法自动映射到createTime属性上全部是null。这是新手最容易忽视的配置项之一我亲眼见过有人排查了半天最后发现就是缺了这一行。3. 开发规范从哪里开始约束Result、异常处理、JWT一个都不能少3.1 统一返回结果Result类所有接口的标准信封在苍穹外卖中所有接口的响应数据都封装在ResultT这个类里。它有三个核心字段code响应状态码1表示成功0表示失败、msg提示信息、data实际数据。用户端登录成功后返回的UserLoginVO、查询菜品返回的ListDishVO都会被包进这个信封里。public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success() { ResultT result new Result(); result.code 1; result.msg 成功; return result; } public static T ResultT success(T object) { ResultT result new Result(); result.data object; result.code 1; result.msg 成功; return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.msg msg; result.code 0; return result; } }为什么一定要统一因为前端处理响应时只需要判断code是不是1是1就取data渲染页面不是1就弹msg提示。如果不统一有的接口返回{code:1}有的返回{status:200}前端就要为每个接口写不同的解析逻辑这种体量的项目根本维护不动。3.2 全局异常处理器把深夜排查的崩溃扼杀在摇篮里Day01的工程里一定会有GlobalExceptionHandler配合Spring Boot的RestControllerAdvice注解使用。它的作用很简单把Service层抛出的业务异常统一转换成Result返回给前端而不是让Spring返回一堆默认的500错误页面。我试过这么干写登录功能时故意让它抛出一个PasswordErrorException看看返回结果——前端拿到的就是code0msg密码错误非常干净。如果没做这个全局异常处理前端可能拿到一整页HTML或者一大段异常堆栈既没法提示用户也可能泄露后端实现细节。这里有一个关键设计自定义异常类 业务异常抛出。区分业务异常和系统异常非常重要。业务异常比如密码错误、账号不存在是用户操作导致的应该返回友好提示系统异常比如数据库连接失败是环境问题需要记录日志并返回系统繁忙。3.3 ThreadLocal和JWT员工登录后如何记住你管理端员工登录是Day01的核心业务。流程是这样员工输入用户名密码 → 后端查employee表校验 → 校验通过后生成一个JWT令牌返回给前端 → 前端后续每个请求都在Header里带上这个令牌 → 后端拦截器解析令牌能解析通过就放行解析失败就返回401。JWT本身包含三段信息Header声明算法、Payload声明携带的数据比如员工id、Signature签名。签名的作用是防篡改——令牌一旦被修改签名校验就会失败。在苍穹外卖中JWT的签发和校验都封装在JwtUtil里密钥和过期时间写在application.yml中。// 登录成功后签发JWT MapString, Object claims new HashMap(); claims.put(empId, employee.getId()); String token JwtUtil.createJWT(secretKey, ttlMillis, claims);这里我要特别提一下ThreadLocal。管理端登录成功后后续的很多业务操作比如新增菜品需要记录create_user为当前登录员工id。但Controller、Service、Mapper层层调用怎么把当前登录id传下去用参数传是最笨的方法。苍穹外卖用的是ThreadLocal——在拦截器解析出员工id后存入ThreadLocal在业务代码中需要时直接取出来用请求结束再清理掉。// 拦截器中存入 BaseContext.setCurrentId(claims.get(empId)); // 业务代码中取出 Long currentId BaseContext.getCurrentId();这个设计思路在真实企业项目中非常常见也是后续开发创建人、修改人字段的核心机制。Day01能把这一整条链路捋清楚后面的开发会非常顺畅。4. 本地上传图片功能为什么Day01就要碰它以及完整实现思路4.1 本地是什么意思这是文件存储方案的落地方案之一本地上传图片是苍穹外卖中一个很典型的功能场景——管理员在管理端上传菜品图片、logo、分类图标图片需要被保存到服务器上并且能通过URL被浏览器访问到。为什么要重点讲这个功能因为图片上传在后面的菜品管理、套餐管理、分类管理中都会反复用到。Day01实现的是一个通用能力后面所有业务模块直接调用这个能力即可。所谓本地上传就是把图片文件保存到运行后端服务的这台机器磁盘上而不是保存到阿里云OSS、腾讯云COS这类的对象存储云端。本地存储的优点是不依赖第三方服务开发调试方便缺点也明显——文件跟服务器绑死了如果部署多台后端机器图片会散在各台机器上服务器磁盘满了会影响服务重启可能丢失没有CDN加速。所以在生产环境中对象存储才是主流方案。但作为学习项目本地存储能让你完整理解文件上传 → 文件保存 → 文件访问这条链路等理解了再去接OSS就是换个存储实现而已。4.2 接口设计路径、参数、返回值的规范苍穹外卖中本地上传图片的标准接口路径是POST /admin/common/upload参数是表单字段名file类型是MultipartFile。上传成功后返回图片的访问URL比如http://localhost:8080/imgs/xxx.jpg。后端收到这个请求后要做四件事判断file是否为空为空则抛异常防止前端传了个空文件上来。生成存储目录和文件名保存到本地磁盘。计算出这张图片的访问URL虚拟路径。把URL封装进ResultString返回给前端。对应的核心代码大概长这样PostMapping(/upload) public ResultString upload(MultipartFile file) throws IOException { // 1. 校验文件是否为空 if (file null || file.isEmpty()) { throw new UploadFileErrorException(文件不能为空); } // 2. 生成存储文件名UUID 原始文件后缀 String originalFilename file.getOriginalFilename(); if (originalFilename null || originalFilename.indexOf(.) -1) { throw new UploadFileErrorException(文件名不合法); } String suffix originalFilename.substring(originalFilename.lastIndexOf(.)); String fileName UUID.randomUUID() suffix; // 3. 保存到本地磁盘 File dir new File(basePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(basePath fileName)); // 4. 返回访问URL String url /imgs/ fileName; return Result.success(url); }4.3 UUID命名和目录拆分避免你硬盘里全是1.jpg文件名为什么不用原始文件名比如1.jpg两个原因一是重名问题——两个管理员都上传1.jpg后一个会把前一个覆盖掉二是文件名里可能带特殊字符、中文URL访问时容易出各种兼容问题。所以最稳妥的方案是用UUID或者时间戳 随机字符串来保证全局唯一。加后缀名时只截取原始文件名的后缀部分比如.jpg这样兼容了用户上传.png、.webp等格式。存储路径方面视频里可能直接用了D:/upload或项目根目录下的upload文件夹。在实际项目中不建议把路径直接写死在代码里应该放在配置文件中sky: file: # 上传文件保存的本地目录 path: D:/upload/这样以后要改存储位置只需要改配置不需要重新打包代码。4.4 静态资源映射让URL能找到磁盘上的图片图片保存到磁盘后前端拿着/imgs/xxx.jpg去访问为什么能访问到这不只是把文件放到某个目录就行的还需要后端把URL路径和磁盘目录做个映射。在Spring Boot里通过实现WebMvcConfigurer接口的addResourceHandlers方法完成映射Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Value(${sky.file.path}) private String filePath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/imgs/**) .addResourceLocations(file: filePath); } }注意这里的file:前缀不能省略。addResourceLocations需要的是一个文件系统URLfile:前缀表示从本地磁盘读取。如果你漏了这个前缀Spring会把它当成classpath路径去解析然后图片永远404。这就是热词苍穹外卖本地上传图片背后最核心的两个步骤——保存文件和映射访问路径。如果你能把这两步的原理讲清楚说明你对Spring Boot文件处理已经有基本的理解了。4.5 上传大小限制与类型校验新手几乎必踩的隐性坑Spring Boot默认限制单次上传文件大小为1MB。你测试时如果传个3MB的图片控制台会报MaxUploadSizeExceededException前端拿到的是一段英文异常提示。要解决这个问题在配置文件中放开限制spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB另外上传接口通常还要做文件类型校验——比如图片仅允许jpg、jpeg、png、webp等常见格式避免用户传一个.exe上来。简单的做法是校验文件后缀名更严谨的做法是校验文件的MIME类型甚至读取文件头部的魔数文件签名。Day01阶段做后缀名校验就够了但你要有这是安全问题的意识。5. 实际开发中遇到的坑从启动报错到图片404的完整排查记录5.1 端口被占用服务启动直接失败Day01第二天我启动服务时控制台直接报Port 8080 was already in use。原因是我之前某个窗口没关还在占用8080端口。Windows下用命令查看占用情况netstat -a -o | findstr 8080找到占用进程的PID后再执行taskkill /F /PID 进程号杀掉。Linux下则是lsof -i:8080。这类问题不复杂但出现的频率非常高建议直接背下命令。5.2 图片上传成功了但访问时404这是我在做本地上传图片时踩的最深的一个坑。文件确实是上传成功了——磁盘目录里可以看到图片文件——但浏览器访问/imgs/xxx.jpg就是404。排查过程是这样的先确认后端是否配置了映射。检查WebMvcConfiguration里addResourceHandler的路径是/imgs/**访问路径是/imgs/xxx.jpg一致。再确认磁盘路径。配置文件里写的是D:/upload/但上传接口的保存路径用的却是拼接的相对路径比如upload。最后发现代码里保存文件时用了new File(basePath)而basePath竟然是从配置文件读取的拼接错误——多了一个斜杠或者少了一段目录。最终发现问题是file:D:/upload/这个写法没错但保存文件时磁盘路径末尾没有带/导致文件实际存到了别的地方。这种看似配置对但实际不生效的情况最好用最简单的办法验证——写一个临时接口打印new File(filePath).getAbsolutePath()看看实际路径到底是什么。一次打印问题立刻现形。5.3 上传文件后缀被拦截前端删了后缀导致类型校验失败有一个很有意思的场景管理员在浏览器里选择了一个名为美食.JPG的图片Spring拿到原始文件名时后缀是大写的.JPG。如果你校验后缀时写的是jpg.equals(suffix.toLowerCase())没问题但如果写的是jpg.equals(suffix)就直接返回文件类型不合法。另一个情况是部分前端组件在拼接图片预览地址时会把原始文件名传给后端而后端又把整个文件名存进数据库。结果数据库里存了/imgs/2df5a8fa-xxxx.jpg这样的值但前端拼接URL时多拼了一层路径也会导致404。这类问题排查起来很费时间建议上传成功后直接打印返回的URL手动复制到浏览器里打开试试能快速定位是后端生成URL的问题还是前端拼接的问题。5.4 开发联调阶段前端请求后端的跨域问题管理端前端跑在http://localhost:9528开发模式后端跑在http://localhost:8080。前端直接调用http://localhost:8080/admin/xxx接口浏览器会有跨域限制请求会被拦截。苍穹外卖的Day01一般会通过Nginx做反向代理来解决——前端请求走NginxNginx把带/api前缀的请求转发到后端的8080端口。你也可以在后端配置跨域过滤器CORS来放行。不管用哪种方案你都要理解这个问题的本质浏览器出于安全考虑默认不允许某个源协议域名端口的页面去请求另一个源的接口。所以做前后端分离项目跨域问题几乎避不开必须提前规划方案。6. 关于Day01我最后想分享的几点体会如果你正在学苍穹外卖或者打算用这个项目做毕业设计、简历项目我给你几条基于实际操作的参考建议第一Day01不必贪快。很多人一天内把Day01视频看完代码敲完就感觉学会了。但第二天开始做分类管理时发现Controller到底该调用哪个Service、VO和DTO的区别是什么全都模糊了。我建议Day01至少安排两天第一天照视频敲一遍第二天不看视频自己独立从头搭一遍工程、写一遍登录和上传接口。第二遍会冒出大量第一遍没注意到的问题——这些问题的处理过程才是真正的收获。第二关于图片上传会调接口和理解原理是两码事。本地上传图片虽然不复杂但它串起来的知识点非常密集MultipartFile的请求解析、磁盘文件的读写、Spring MVC的静态资源映射、MIME类型与文件扩展名、HTTP响应中的URL拼接。任何一个点没吃透换个场景你就会卡住。比如面试官问为什么这张图片放在这个目录就能被URL访问到你如果只回答配置了静态资源映射那基本等于没回答你要能说出URL到磁盘目录的映射规则以及file:前缀的含义才是真正懂了。第三养成读源码的习惯。苍穹外卖本身就是一个很好的源码阅读对象因为它代码量不大命名规范分层清晰。Day01里你可以从Result类开始读然后看GlobalExceptionHandler、JwtUtil、BaseContext这几个类加起来不超过400行却涵盖了JWT认证、上下文管理、异常处理多个核心机制。读完之后你再去看Controller和Service的调用关系会感觉整个项目的脉络特别清楚。至于本地上传图片在生产环境中的替代方案——对象存储比如阿里云OSS、MinIO——它的整体调用链路其实和本地存储是一样的接收文件、生成唯一文件名、保存到存储服务、返回访问URL。区别只在于保存到磁盘变成了调用云服务SDK上传。把本地实现搞懂了将来接OSS你只需要替换一行核心代码。这也是我怕反复强调理解链路的原因——技术会变但骨架不变。Day01的门槛确实不高但我亲眼见过不少同学在Day02、Day03崩溃后回头重新学Day01。原因不是Day01的内容多难而是他们当时只记了怎么做没记为什么这么做。希望你做笔记的时候多写一点原因少抄一点代码。这样二十多天的项目走下来你收获的不只是一个能跑的苍穹外卖而是一整套工程化思维。
返回列表