
今天是接手苍穹外卖项目的第二天。昨天把开发环境梳理了一遍装好了 MySQL、配置了 Maven 仓库也把前端 Vue 项目跑起来了。今天的目标很明确把后端骨架立起来打通本地数据库连接顺便处理掉登录流程和图片上传这两个老环节。平时写业务逻辑的时候最怕在两件小事上卡壳一是文件上传之后前端访问不到图片二是接口鉴权配置出错导致路径全被拦截。今天正好把这两块一次解决掉。顺便说明一下这篇日记不是完整的项目教程而是记录我实际动手时遇到的关键节点和取舍思路。如果你也在跟这个项目或者手头正打算做一个带后台管理的外卖点餐系统那么看这篇的价值在于提前知道哪些环节容易浪费你一两个小时。1. 第二天开局先把后端工程骨架立起来1.1 用 Spring Initializr 快速创建项目还是手动搭很多教程会带着你从 IDEA 里直接 New Project选好 Spring Boot 版本然后勾选依赖。我第一天晚上试了一下发现一个问题IDEA 自带的初始化器在国内网络环境下有时候下载依赖极慢。所以我第二天早上换了个更直接的方式去 Spring Initializr 网站手动生成项目包再用 IDEA 打开。这样能保证生成的压缩包里的 pom.xml 结构干净不会夹带多余的自动生成内容。选择依赖的时候我只勾了四个基础项Spring Web、MyBatis、MySQL Driver、Lombok。Swagger 我后面手动加Validation 也一起放进去。为什么先不加 MyBatis-Plus因为苍穹外卖这个项目本身用的就是原生 MyBatis我没打算中途换框架虽然 MyBatis-Plus 在单表操作上确实省力但跟着项目原生的技术栈走后面看 XML 里的 SQL 会更清楚。创建完项目后第一件事就是改 pom.xml。我把 Spring Boot 版本固定成了 2.7.x 系列原因很简单这个版本和当前用的 JDK 8 配合最稳不会出现 Java 版本不兼容导致启动失败的问题。接着补上需要的依赖坐标这里有一个小提醒Lombok 的版本最好让 Spring Boot 父工程统一管理不要自己手写版本号否则可能和其他依赖的编译版本产生冲突。1.2 包结构让 controller、service、mapper 各就其位工程建好之后我参照企业中常见的分层结构建包。没有用按 feature 组织包的方式因为苍穹外卖这个项目的业务模块彼此交叉很多按技术层分包反而更直观。我建了这样几个基础包controller接收 HTTP 请求做参数校验和结果返回service业务逻辑处理事务边界在这里控制mapper数据访问层接口配合 resources 下的 mapper XML 文件entity数据库表对应的实体类dto接收前端参数的传输对象vo返回给前端的视图对象common通用类比如结果封装、全局异常处理config配置类interceptor拦截器utils工具类分包之后最大的一个收益是第二天下午写上传接口和登录接口时基本不需要来回跳框架文件每个类放在固定的位置IDEA 的文件定位也快很多。1.3 配置文件的拆分开发环境和生产环境分开Spring Boot 项目里配置文件不应该只放在一个 application.yml 里。我把内容拆分成了 application.yml 主配置、application-dev.yml 开发环境配置、application-prod.yml 生产环境配置。主配置文件里只放应用名、端口号、当前激活的 profile 这类通用信息数据库连接、Redis 地址、文件上传路径这些和环境强相关的内容全部放到对应的 profile 文件里。比如开发环境的配置我写的是这样的server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sky_take_out?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf-8 username: root password: root123456这里面有一个细节值得说url 参数里我加了serverTimezoneAsia/Shanghai。如果少了这个MySQL 8.x 的驱动在连接时会报时区错误这个问题在很多人第一次启动项目时都会遇到报错信息会让你一脸懵但其实只是时区设置问题。1.4 启动验证第一次看到 Spring Boot 的启动日志写好配置后我直接启动主类。看到控制台输出 Spring Boot 启动成功的那个标志性日志时心里才算踏实。然后去浏览器访问/路径虽然是一个默认错误页但至少说明 Web 服务起来了。这里我遇到一个小坑因为我没有在 controller 里写任何接口所以访问根路径会出现 Whitelabel Error Page这非常正常不用担心。2. 数据库连接与 MyBatis 配置跳过那些容易忽视的小细节2.1 建库建表给员工表和菜品表留足字段空间第二天上午的核心任务之一是确认数据库能正常跑通。在图形化工具里我执行了建库脚本创建了sky_take_out这个库然后开始建 employees 表。苍穹外卖的 employee 表在真实的项目里叫 employee 还是 staff不同版本可能不一样我这边用的是 employee。字段设计上保留核心信息id主键自增username员工登录账号唯一password加密后的密码注意不是明文name员工姓名phone手机号gender性别id_number身份证号status账号状态1 表示启用0 表示禁用create_time、update_timecreate_user、update_user之所以把 create_user 和 update_user 单独列出来是因为苍穹外卖的后台操作需要知道这条数据是哪个人经手的后面做操作日志或权限追踪很有用。菜品表 dish 我又单独立了一张。和员工表关联度不高主要是为了后面做菜品管理时能够通过分类 id 关联到分类表以及通过图片字段保存上传后的图片路径。2.2 MyBatis 的 SQL 映射文件先跑通一个最简单的查询在项目里我不喜欢把 SQL 全写在注解里更习惯维护 XML 映射文件。所以在 resources/mapper 目录下建了 EmployeeMapper.xml。先写一个最简单的查询验证一下 MyBatis 的生效情况?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.sky.mapper.EmployeeMapper select idgetByUsername parameterTypestring resultTypecom.sky.entity.Employee select * from employee where username #{username} /select /mapper然后写一个 Controller 的临时接口去调用它看到查询结果能从数据库返回时说明 MyBatis 的映射配置已经没问题了。这个临时接口在正式开发时记得删掉不然会暴露内部数据。2.3 HikariCP 和 Druid 的选择Spring Boot 2.x 默认的数据库连接池是 HikariCP性能优秀也不怎么需要调参。很多人会额外引入 Druid因为它带了监控功能可以看 SQL 执行情况。但对于苍穹外卖这个体量的项目HikariCP 完全够用我不建议在第二天就引入 Druid节省时间后面真需要看慢 SQL 时再换也不迟。数据库这块还有一个容易出错的地方pom.xml 里 MySQL 驱动的 scope。很多人写依赖时习惯加一个scoperuntime/scope这会导致某些情况下 IDE 在编译期找不到驱动类从而报错。我直接把 scope 去掉让它默认 compile这样最省心。3. 登录鉴权开发JWT 生成的 token 和拦截器的一场配合3.1 为什么登录用 JWT而不是 Session苍穹外卖这种前后端分离项目后端和前端跑在不同的端口上如果用 Session 共享登录状态还需要额外配置跨域携带 Cookie比较麻烦。JWT 的思路完全不同用户登录成功后后端生成一个签名字符串返回给前端。前端每次请求时把这个字符串放到请求头里。后端通过拦截器解析这个字符串确认是否合法。这个方案的好处就是天然适合分布式环境不依赖服务器内存存储会话扩展的时候省掉不少机器之间的会话同步问题。3.2 编写 JWT 工具类不要自己造轮子JWT 的生成和解析网上有非常多现成的工具类。我没选择自己从零写而是参考了成熟方案核心依赖用的 jjwtdependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency工具类里封装了三个方法创建 JWT、解析 JWT、从 JWT 中获取指定字段。生成 token 时我会把员工的 id 作为自定义载荷放进去这样后面每次请求都能通过 token 知道当前操作人是谁。要注意 jjwt 0.9.x 版本内部依赖了 jaxb 相关类JDK 8 下没问题。如果你用的是更高版本的 JDK可能需要在 pom.xml 里额外引入对应的依赖才能正常编译运行。3.3 登录接口背后的三层结构Controller、Service、Mapper登录接口从接收参数到返回 token流程不难但是结构上要清晰。我在 Controller 层只做一件事接收用户名密码调用 Service把结果返回。密码的校验不能放在 Controller 里因为 Controller 更关注 HTTP 层面的参数处理而校验密码属于业务规则。实际编码时我写了三层Mapper 根据用户名查用户Service 对比密码、检查账号状态Controller 根据结果生成 token 返回这里有一个小细节数据库里存的密码是经过 MD5 加密的不是明文。苍穹外卖项目里初始化密码通常是一串经过加密的值。所以在 Service 校验时需要把前端传过来的密码先用 MD5 加密再跟数据库里的值做对比。我一开始直接拿明文去比较自然一直登录失败后来查了初始化 SQL 脚本才发现密码字段存的是密文。3.4 拦截器注册哪些路径要放行哪些要拦截生成 token 只是第一步。如果后端接口不对请求头做校验那么 token 就形同虚设。所以我在拦截器里做了两件事从请求头获取 token调用 JWT 工具类解析 token如果解析失败则抛出异常同时需要对一些路径放行不然登录接口自己的请求也会被拦截下来形成一个死循环登录接口要调的时候发现 token 校验失败返回 401前端拿不到 token永远登录不进去。我在 WebMvcConfiguration 里这样配置registry.addInterceptor(new JwtTokenAdminInterceptor()) .addPathPatterns(/admin/**) .excludePathPatterns(/admin/employee/login);意思是拦截 /admin/ 下的所有请求但放行登录接口。这样前端先调登录接口拿到 token后续再访问其它管理端接口时拦截器就能识别 token 并放行。3.5 拦截器和全局异常处理联手让错误信息更友好拦截器里如果直接抛出异常前端拿到的错误信息可能是一大段英文堆栈。为了统一我在 common 包里加了全局异常处理器使用RestControllerAdvice注解。这样拦截器抛出的业务异常会被集中捕捉返回给前端一个标准 JSON{ code: 401, msg: 未登录或token已过期, data: null }这个格式和苍穹外卖项目的要求是一致的前端拿到 code 不等于 0 时就知道请求出了问题。第二天下午我花了不少时间调这个第一次用拦截器时没接全局异常处理器导致前端收到的错误信息乱七八糟后来把两者接上才顺畅。4. 本地上传图片功能不只是把文件塞进磁盘那么简单4.1 为什么选择本地上传而不是云存储做苍穹外卖这种练习项目时文件存储的方案一般是两个方向阿里云 OSS 和本地磁盘存储。热搜词里很多人都在搜苍穹外卖本地上传图片说明不少人和我一样选择把图片保存到自己电脑的磁盘目录而不是接入云端。本地存储最明显的优势是简单不需要开通云服务、不需要申请密钥、也不需要担心调用云 API 时产生的费用。缺点是图片访问量大了之后磁盘压力会变大且不适合部署到多台服务器。但对于练习项目完全够了。如果你后续准备上线真实项目再把存储层替换成云存储即可接口层面的改动不会太大。4.2 文件上传接口的三种要素MultipartFile、UUID、存储路径苍穹外卖的后台管理端通常需要一个通用的图片上传接口。我是在 CommonController 里写了一个上传接口对应路径/admin/common/upload。接口的核心就几行代码PostMapping(/upload) public ResultString upload(MultipartFile file) { String originalFilename file.getOriginalFilename(); String extName originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString() extName; File dir new File(basePath); if (!dir.exists()) { dir.mkdirs(); } try { file.transferTo(new File(basePath newFileName)); } catch (IOException e) { throw new RuntimeException(文件上传失败); } return Result.success(basePath newFileName); }这里有几个值得留意的点文件名用 UUID 重新生成是为了防止重复。如果不改文件名别人传一个a.jpg第二次再传一个同名文件后者就会覆盖前者。UUID 生成的字符串重复概率极低可以放心。原文件名里获取扩展名的写法要小心。lastIndexOf(.)得到的下标位置如果文件名里包含多个点比如photo.backup.jpg这种方式仍然能正确取到.jpg。但如果文件本身没有扩展名substring会出问题所以更严谨的做法是先判断是否存在.。上传目录要提前创建好。file.transferTo方法不会自动创建父级目录目标目录不存在时它会直接抛 IOException。所以每次上传前都要检查目录是否存在不存在就用mkdirs()创建。mkdirs()可以连级创建目录如果直接用mkdir()遇到多级目录不存在的时候还是会报错。4.3 配置静态资源映射上传之后前端怎么访问图片图片上传成功之后你会在本地磁盘看到一个文件但浏览器访问不到这是很多人第一次做本地文件上传时最容易卡住的地方。正常情况下Spring Boot 的静态资源默认放在classpath:/static/目录下。你把图片上传到磁盘任意位置比如D:/upload/这个路径并不在 Spring Boot 的静态资源扫描范围之内所以浏览器访问http://localhost:8080/upload/xxx.jpg会得到 404。解决办法是在配置类里重写addResourceHandlers方法Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: basePath); }这里的file:前缀不能省略。它表示这个路径是一个文件系统路径而不是 classpath 路径。如果你写的时候忘了加那么资源映射不会指向磁盘上的文件夹图片依然无法访问。路径映射还有一个细节如果 basePath 末尾没有加上分隔符/那么拼接出来的资源路径会出错。我建议在配置类里读取配置值之后统一补齐末尾的/这样无论是 Windows 还是 Linux 都能正常工作。4.4 前端如何接收返回的图片地址并回显上传接口返回的是一段图片访问路径前端拿到之后需要拼接成完整的 URL然后放到img标签的src属性里。我用的前端是 Vue 项目开发环境下通过 Vite 的代理或者 nginx 将/admin开头的请求转发到后端 8080 端口。图片路径我是这样处理的baseUrl /upload/xxx.jpg这里的 baseUrl 通常就是前端请求后端的接口地址。在开发环境里我配置了代理所以图片路径直接用相对路径也能访问到。如果你遇到图片 404很多情况不是接口写错了而是代理配置只处理了/admin前缀没有处理/upload前缀导致图片请求没能转发到后端。4.5 上传接口的兼容性MultipartFile 还是 base64有些场景下移动端或者特殊前端会直接把图片转成 base64 字符串传给后端。但苍穹外卖的后台管理端使用的是 Element UI 的上传组件默认会调用上传接口并携带表单数据multipart/form-data所以 MultipartFile 方式是最合适的。如果你在 Postman 里测试上传接口需要注意选择一个叫file的字段而不是随便起名。接口参数名是file如果前端组件配置的 formData 字段名不是这个后端会拿到 null进而报出文件为空之类的错误。我在联调时就被这个问题坑了一次前端上传组件的参数名被另一个同事改成了uploadFile导致后端一直收不到文件。后来让他改回来问题立刻消失。4.6 上传文件大小限制默认 1MB 很可能不够用Spring Boot 对上传文件大小有一个默认限制默认单文件最大 1MB单次请求最大 10MB。这个限制对菜品照片来说明显太小了现在的手机拍出来的照片都是好几 MB上传时会被 Spring 拦截并返回错误。所以我在配置里把这个限制调大了spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB调完之后上传大图就不会再被 Spring 内部拦截了。这里要注意如果你不想改全局配置也可以在前端组件里先做图片压缩再上传不过相对麻烦我直接用调整配置的方式解决。4.7 图片文件上传过程中的隐藏地雷路径与环境差异Windows 和 Linux 的文件路径写法差异在本地开发时可能感觉不到但是换环境部署时就会出问题。比如 Windows 下拼接路径用的是\而 Linux 下是/。如果你在代码里硬编码了盘符比如String filePath D:/upload/ fileName;那么程序部署到服务器上时必然报错。我建议把路径配置到application.yml里然后用Value注入不推荐写死在代码中。另外还有一个容易忽视的权限问题在 Linux 服务器上运行 Spring Boot 应用时如果应用账号对这个上传目录没有写权限上传也会失败。这个问题的排查思路和其他文件操作类似先用命令行手动测试目录权限再回到代码层面排查。很多人在本地上传好好的一部署到服务器就上传失败大概率就是这个问题。5. 菜品分类接口连起来跑通一个完整业务5.1 菜品分类的数据结构parentId 还是单层分类写菜品管理之前需要先定清楚分类表的设计。苍穹外卖的分类表 category 比较简单主要字段是分类名称、类型、排序、状态。有一个字段值得注意就是 type它区分了菜品分类和套餐分类。用一个字段控制比建两张表更轻量查询时通过 type 过滤即可。我没有在分类表里设计 parentId 字段因为苍穹外卖里菜品分类只需要一层就够了。如果你要自己扩展成两级分类比如热菜-宫保鸡丁这种结构那再加 parentId 会更合理。对于当前项目来说越简单越不容易出错。5.2 新增分类接口的校验逻辑实现新增分类时要注意几个校验点分类名称不能为空分类名称在当前类型下不能重复排序值不能为负数这些校验逻辑我放在 Service 层而不是 Controller 层。原因是 Controller 层负责参数绑定和结果封装Service 层才是业务规则真正发挥作用的地方。如果在 Controller 里做校验后面其他 Controller 调用同一个 Service 时逻辑会不统一。5.3 分类分页查询MyBatis 分页插件的使用管理端的分页查询非常常见。当时我选了 PageHelper 这个分页插件因为它和原生 MyBatis 配合很顺不需要改动现有 Mapper 接口的写法。使用方式比较优雅查询前使用静态方法设置页码和每页大小后面紧跟的一条查询就会自动拼接 limit查询完成后可以获取 PageInfo 对象拿到总记录数、总页数等信息。使用这个插件有一个前提PageHelper 版本需要和 MyBatis 版本兼容。否则启动时会报分页插件不生效的问题。我一开始用了最新的 PageHelper结果和 MyBatis 的版本不匹配分页一直不生效所有数据一次性返回。后面把 PageHelper 版本降下去问题才解决。你可以先用简单的 XML 手写 limit 验证分页是否正常再决定要不要引入插件。对于学习项目来说手写分页甚至更能加深理解。5.4 修改分类状态启用和禁用的坑分类有一个状态字段1 表示启用0 表示禁用。修改状态时前端传过来分类 id 和目标状态后端做一个简单的 update 操作。这里有一个业务上要注意的点如果分类下面已经关联了菜品那么禁用分类时菜品的状态就变得比较微妙。在苍穹外卖项目里启停分类并不影响菜品的展示逻辑但是真实的外卖系统中禁用分类通常意味着该分类下的商品全部下架。这里存在一个前后端逻辑一致性的问题。我建议你写这个功能时在 Service 里加一步判断如果分类被禁用可以把该分类下的菜品状态同步禁用。或者至少在返回值里给前端一个提示让前端知道这个操作可能会连带影响菜品。5.5 Controller 返回结果统一封装Result 类的妙处不管新增、修改还是查询我一直用一个统一的返回类型 Result。它长这样public class ResultT implements Serializable { private Integer code; // 编码0表示成功其他表示失败 private String msg; // 错误信息 private T data; // 数据 }这种统一封装最大的好处是前端处理逻辑简单只要判断 code 是不是 0就能决定要不要展示报错信息。把需要返回的数据放在 data 字段里结构不会乱。很多初学者在写接口时喜欢直接返回一个实体对象比如返回 Employee、Category 这样这样也能运行但问题在于一旦接口需要同时返回额外信息你就得修改返回类型而前端也需要跟着改。使用 Result 封装后后续扩展接口时基本不动响应结构。6. 前端联调与跨域问题本地接口通了不代表一切顺利6.1 前端项目结构管理后台和用户端分开苍穹外卖的前端部分是分为两个工程的一个供管理员使用的后台管理系统一个供用户点餐的小程序或者 H5 端。第二天我主要联调的是后台管理系统。启动时我用的是 Vue 开发服务器默认端口是 8080 的话就要小心和后端端口冲突。我的做法是让前端跑在 5173 端口后端跑在 8080 端口。前端项目里配置了代理把所有/admin开头的请求转发到后端 8080。这样在后端打接口时不需要自己去处理跨域因为浏览器访问的是同源的 5173只是请求被代理转发到了后端。6.2 跨域问题和 CORS 配置开发时最简单的处理如果你不用代理而是让浏览器直接请求http://localhost:8080这个地址那就会遇到跨域问题。浏览器会拦截非同源的请求控制台里报错信息一大串。解决跨域可以有几个方向后端配置 CORS 过滤器前端配置代理转发用浏览器插件忽略跨域我推荐用前端代理的方式因为这不影响生产环境且更符合真实部署的架构。如果你非要在后端放开跨域可以通过配置类实现Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这个配置相当于把所有接口都放开了跨域限制。开发环境很方便但这在生产环境会有安全风险。我的看法是练习项目用可以线上项目最好收敛到具体域名。6.3 联调中常见的返回格式错位前后端联调时最容易出的问题就是字段名对不上。后端返回的字段叫name前端却用categoryName去渲染结果就是列表显示空白。遇到这种情况不要急着改代码。先在浏览器开发者工具里看一下接口返回的 JSON再去看前端代码里绑定了哪些字段两相对比就能定位问题。我的经验是在写好一个接口后先用 Postman 或浏览器直接访问接口确认返回的 JSON 符合预期再进行前端联调。不要跳过后端自测直接对接那样出了问题会不知道是后端返回错了还是前端解析错了。6.4 登录状态持久化token 存哪里前端拿到 token 之后需要保存下来之后每次请求时都带上。我把 token 存到了浏览器的 localStorage 里这样做的好处是刷新页面后登录状态不会丢失。请求拦截器里从 localStorage 读取 token然后放到请求头中这个模式在 Vue 项目里用得非常普遍。如果存储到 sessionStorage则刷新页面后登录状态还会在但关闭浏览器再打开就没了相当于要求用户重新登录。对于外卖后台管理这种系统一天可能要登录很多次体验不太好所以 localStorage 更合适。7. 第二天的报错日志复现定位问题的完整链路7.1 重复的端口占用当我以为项目启动失败时下午第一次启动后端时控制台立刻爆出端口被占用的错误。原因是我之前跑过一个临时接口的 Spring Boot 实例没有关闭两个进程占用了同一个 8080 端口。解决办法很简单netstat -ano | findstr 8080找出占用端口的进程 id然后结束掉它。这个错误很基础但非常常见。我的建议是在 IDEA 里关闭程序时确保进程真的退出了不要只关闭了运行窗口。有时候你会看到 IDEA 里标着已经停止但后台进程其实还在运行这种情况在 Windows 上很典型。7.2 图片上传成功但访问 404静态资源映射和目录权限上传接口返回了成功文件名也是正常的但是浏览器里访问图片时 404。我排查的链路是这样的先确认上传目录确实存在文件再确认访问路径是否正确最后检查配置类是否生效最终发现是配置类里没有加file:前缀导致资源映射没有指向文件系统。这种低级错误确实容易踩复盘下来发现就是因为没有理解addResourceLocations的参数格式。另外如果你自定义了配置类且这个类放在主启动类扫描不到的包路径下那么配置也不会生效。我在写配置类时一直注意让它放在主启动类所在的根包及其子包下这也是一个容易忽略的点。7.3 分页数据总数不对PageHelper 的静态方法陷阱PageHelper 在使用上有一个细节分页静态方法和 SQL 查询之间不允许有其他查询操作。一旦中间插了别的 SQL分页插件会把分页作用于前一条查询导致你得到的数据完全不对。比如下面这种写法就很容易出错PageHelper.startPage(page, pageSize); Employee employee employeeMapper.selectByUsername(admin); // 这条会受影响 ListCategory list categoryMapper.selectAll();因为 PageHelper 会把分页参数应用在紧接着它的第一条查询上。如果你在 startPage 之后先查了别的数据分页就会作用到那条查询上而不是你真正想要分页的那条。为了避免这个问题我一般会在调用分页之前把所有可能执行的查询都清空确保 startPage 紧跟着目标查询语句。7.4 登录后接口仍返回 401拦截器排除路径没有生效前端已经拿到 token但是访问员工列表接口时依然提示未登录。我检查了拦截器配置明明排除了登录接口但别的接口应该被拦截如果 token 解析成功就能通过。后来发现是前端请求头里没有正确携带 token而不是后端拦截器的问题。在浏览器控制台的网络面板里你可以看到请求头是否包含了 Authorization 字段。如果没有说明前端请求拦截器没配好。修复方法是在 axios 拦截器里统一设置service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; });需要在后端确认 JWT 解析时读取的请求头名称我这里用的是Authorization。如果前后端对这个名称约定不一致后端也会认为请求未携带 token。7.5 时间字段的格式化问题JSON 和数据库的时区不一致第二天晚些时候测试员工列表接口时发现 createTime 字段返回的时间比预期少了八个小时。原因是 JVM 默认时区是 UTC 的而数据库连接时用的是上海时区。解决办法是在配置文件里显式设置spring: jackson: time-zone: GMT8这样后端返回 JSON 时时间字段就能按北京时间输出。这个问题在本地调试时如果不太在意可能不会立刻暴露因为很多前端展示时会把时间交给组件格式化。但是如果你用 Postman 直接看接口原始返回就能发现时间对不上。8. 第二天结束后的进度梳理与后续计划一天下来我完成了下面这些工作创建 Spring Boot 后端工程配置好开发环境和生产环境分离的 profile建立数据库连接MyBatis 基础映射跑通完成员工登录接口JWT 生成和拦截器校验链路完整实现本地上传图片功能配置静态资源映射图片上传后可以正常访问完成菜品分类的分页查询和新增接口前端完成登录页、分类管理页的基础联调解决了跨域、时间字段、文件大小限制、路径映射等几处典型问题我觉得第二天最大的收获不是往 project 里加了多少行代码而是培养了一种排查问题的习惯。以前遇到报错总喜欢照着错误信息硬猜今天几次排查让我体会到追问题的正确顺序应该是先确认数据链路通不通再排查代码逻辑最后再考虑环境配置。很多时候代码看着没问题但环境配置差一个参数整个功能就是不工作。如果你也在动手做苍穹外卖项目我建议你在第二天务必把文件上传 静态资源访问这个链路趟平。因为这个功能贯穿后续的菜品管理、套餐管理、分类图标等多个页面。每次做新增功能的时候如果还要专门花时间去看怎么上传图片不仅打断思路也会让项目节奏变得拖沓。接下来我准备在第三天继续完善菜品管理模块包括菜品的增删改查把 dish 表和分类表关联起来。预计会碰到的问题大概率是菜品关联的分类被删除时的外键处理以及批量删除菜品时的数据一致性。到时候再来记录。