
“苍穹外卖”这四个字对做过Java全栈培训项目的朋友来说应该不陌生它是一套非常典型的餐饮外卖教学项目前端用Vue后端用Spring Boot还有管理端和用户端两个入口。Day1是整套项目的奠基环节也是很多人第一次接触“完整项目”时最容易卡住的一天。说它难吧接口都很直接说它简单吧环境、数据库、前端联调、图片上传每一个环节都能拉胯。这篇就围绕苍穹外卖Day1的实际完成路径把从环境准备到管理端登录、再到本地上传图片这一整套过程的逻辑和踩坑点都梳理清楚给正在跟着做的人一份可以直接“抄作业”的实操笔记。1. Day1开始的正确姿势先想清楚这个项目到底在练什么1.1 苍穹外卖的业务全景苍穹外卖模拟的是一个真实的外卖平台包含管理端和用户端两大模块。管理端面向商家和平台运营人员功能包括员工登录、员工管理、分类管理、菜品管理、套餐管理、订单管理、统计报表等用户端面向普通用户包括微信登录、浏览分类、购物车、下单支付、催单等流程。Day1通常只聚焦管理端的骨架搭建很多教程会把员工登录、退出登录、员工分页查询、新增员工这几个功能作为第一天的任务。也就是说Day1的业务目标其实很收敛让管理端能跑起来、运营人员能登录、能在后台查员工和管理员工状态。如果你只看完视频没自己做很容易产生“这不就是几个CRUD吗”的错觉。但真正动手的时候会发现一个完整的项目骨架远不止CRUD统一返回结构、异常处理、JWT令牌、拦截器、跨域配置、静态资源访问、前后端联调这些东西才是Day1真正要练的“内功”。1.2 技术栈清单和选型逻辑苍穹外卖的技术栈非常“标准”它基本就是Java岗位最常见的那一套组合后端Spring Boot 2系、MyBatis、MySQL、Lombok前端Vue、Element UI、Axios工具链Maven、Git、Nginx、Redis在订单、缓存等模块用到Day1不一定涉及鉴权方案JWT MD5这套组合最大的优势是通用性高、资料多、岗位匹配度高。Spring Boot负责把臃肿的配置收拢成约定大于配置MyBatis让SQL保持可控JWT做无状态鉴权Nginx充当静态资源服务器和反向代理。Day1阶段用Redis的场景不多但一定要把环境装上后面缓存菜品、处理购物车都会用到。选型的逻辑其实很值得琢磨为什么不用SpringCloud因为Day1是单体应用引入微服务只会增加部署和排查成本。为什么不用BCrypt做密码加密教学项目为了简化演示多采用MD5加固定的salt真正生产系统当然要换但学习阶段不必过度设计。1.3 Day1的交付物与验收标准做任何项目先定验收标准才不会做得稀里糊涂。我建议Day1按这几个标准自检管理端前端页面能正常打开登录接口能返回token登录成功跳转到首页登录失败提示“账号或密码错误”员工分页查询能根据条件过滤数据正确退出登录接口能清除登录状态本地上传图片接口能接收文件并返回可访问的URL这里有个容易被忽视的点前端页面常常是教程直接提供的很多初学者以为“页面能开项目没问题”但实际上前端请求的是后端接口后端跑不起来页面再漂亮也只是一个壳子。Day1真正要验收的是“前端发起的请求能被后端正确处理”而不是“页面长什么样”。2. 环境搭建与数据库导入这三个坑能让新手卡一整天2.1 版本对齐JDK、Maven、MySQL、Spring BootDay1翻车最多的地方排名第一的绝对不是业务代码而是环境版本不匹配。很多教程用的是JDK 8、Spring Boot 2.x、MySQL 8.x你如果电脑上装的是JDK 17好几个库就会出现兼容性问题最典型的就是javax和jakarta命名空间的差异以及MyBatis版本跟Spring Boot版本对不上导致的启动崩溃。我推荐装环境前先看一眼项目的pom.xml和application.yml确认以下四件事JDK版本必须 pom里java.version指定的版本但不要跨度太大JDK 8跑Spring Boot 2.6通常没问题JDK 17跑某些旧版本会踩javax问题Maven版本不要太新Maven 3.9.x配JDK 17会出现依赖解析问题建议用课程配套版本MySQL版本和信息要写进application.yml的url、username、passwordSpring Boot版本和MyBatis、Lombok版本要互相兼容我接手过好几份学员的报错日志很多是控制台提示Failed to configure a DataSource但自己怎么都查不出原因。最后发现只是application.yml里的密码前后多了个空格。所以强烈建议改配置时用编辑器的全量搜索别靠肉眼。2.2 数据库脚本导入的隐藏依赖苍穹外卖提供了sky.sql脚本里面包含了数据库表结构和若干初始数据。导入时大部分人会遇到两个隐藏问题。第一个是字符集和排序规则不一致。MySQL 8默认的排序规则可能是utf8mb4_0900_ai_ci而脚本里如果写的是utf8mb4_general_ci导入时如果表已存在就会报错。处理方法很简单先用DROP DATABASE IF EXISTS sky_take_out;把旧库清掉再CREATE DATABASE sky_take_out DEFAULT CHARACTER SET utf8mb4;重新创建最后再导入脚本。这样能最大程度避免“表已存在”或“字段长度超限”这种莫名其妙的问题。第二个是脚本里的初始数据包含管理员的账号和密码。密码通常是明文或MD5处理过的密文导入后要确认employee表里有第一条数据否则后面登录接口永远查不到用户你会以为是自己代码写错了。这一步排查成本极低一定要先做。2.3 管理端前端启动与nginx代理苍穹外卖的管理端通常是用Vue写的独立前端项目通过npm启动开发时访问的端口一般是8080后端端口是8081两者不同源。浏览器里发请求时就会遇到跨域。教学项目里处理跨域的方式一般是两种后端WebMvcConfigurer加跨域配置或者前端请求转发到Nginx由Nginx做反向代理。Day1比较推荐用Nginx方案因为后面部署时也会用到而且能让你对“开发环境与生产环境差异”有一个直观认识。一个典型的前端请求地址是http://localhost:8080/api/employee/loginNginx配置大致长这样server { listen 8080; server_name localhost; location /api/ { proxy_pass http://localhost:8081/api/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location /upload/ { alias D:/upload/; } }这里的location /upload/是为了让上传的图片能通过前端地址访问后面第4节还会细说。如果Nginx配置了但响应码一直是404先检查proxy_pass最后有没有保留/api路径。这个细节非常容易错proxy_pass末尾带不带/最后转发的URL完全不一样。3. 管理端登录和员工管理的核心链路3.1 统一返回结果与DTO设计苍穹外卖的后端接口统一使用一个Result类返回数据结构大概是code、msg、data三个字段。前端拿到code1时认为成功否则弹出msg里的错误信息。这个设计的好处是前后端约定清晰后续每一个接口都复用同一个结构不需要每个Controller各自造轮子。对应的前端传来的参数不要直接用Map接收而是定义EmployeeLoginDTO这类DTO对象。用DTO有几个好处能够通过注解做参数校验比如NotNull能够屏蔽多余的字段能够避免直接把前端参数绑定到数据库实体上。登录接口的Controller层代码逻辑非常简单核心就三步PostMapping(/login) public ResultEmployeeLoginVO login(RequestBody EmployeeLoginDTO dto) { Employee employee employeeService.login(dto); MapString, Object claims new HashMap(); claims.put(empId, employee.getId()); String token jwtUtil.createJWT(claims); EmployeeLoginVO vo EmployeeLoginVO.builder() .id(employee.getId()) .name(employee.getName()) .token(token) .build(); return Result.success(vo); }看到这里有没有发现一个问题JWT的生成放在Controller层并不算最优设计更合理的做法是在Service里生成token再组装VO。教学项目为了让大家能一眼看明白所以写得靠前实际开发时建议把令牌生成下沉到Service层Controller只负责接收参数和返回结果。3.2 登录校验与JWT令牌生成登录校验的核心逻辑在Service里先根据用户名查employee表查不到就抛出“账号不存在”查到后比对密码比对失败抛“密码错误”再检查状态是否为1禁用账号要返回“账号已被锁定”。这些异常统一由全局异常处理器捕获转成Result.error()前端就能拿到对应的提示。密码比对这里要注意数据库里存的不是明文而是MD5加密后的密文。查询用户后先对前端传过来的明文密码做一次MD5再和数据库里的密文比对。示例代码大致是这样String encodedPassword DigestUtils.md5DigestAsHex(dto.getPassword().getBytes()); if (!encodedPassword.equals(employee.getPassword())) { throw new PasswordErrorException(密码错误); }这个方案在教学项目里没问题但面试时千万别把“MD5存密码”包装成高安全方案BCrypt才是生产环境的主流选择。MD5的特点是定长、不可逆但查表攻击很容易破解所以哪怕是在教学项目里我也会建议至少加一个固定的salt增加一点破解成本。JWT令牌生成是这个环节的核心。它的本质是把用户信息签名后生成一个字符串服务端不存登录状态客户端每次请求带上这个令牌服务端验签即可。生成时设置过期时间比如2小时注意不要把密码明文塞进Payload因为JWT的Payload是Base64编码不是加密的随便找个网站就能解码。3.3 为什么主键ID要手动设置、状态字段要单独定义第一天做员工管理时很多人会对employee表里的字段产生疑惑。id为什么不自增status为什么是1和0create_time和update_time要不要自己维护先说id。苍穹外卖的employee表设计成插入数据时手动指定ID这是为了方便管理端初始数据固定ID。实际操作中新增员工时如果直接依赖数据库自增后面前端列表、编辑、删除时拿到的ID也很正常所以不需要在这里硬改成手动生成跟着脚本走就行。再说status。这个字段的语义是“账号是否启用”1表示启用0表示禁用。在代码里不要用魔法数字到处比较而是定义一个常量类或者在EmployeeStatus枚举里定义值。比如public static final Integer ENABLE 1; public static final Integer DISABLE 0;这种细节看似不起眼但如果后面做批量操作、条件搜索时到处写1和0项目一变大必然出错。分页查询也有一个容易被忽略的点MyBatis手写分页时要注意PageHelper插件的版本必须和MyBatis兼容用PageHelper.startPage(page, pageSize)之后必须紧跟着第一条SQL查询中间不要穿插其他数据库操作否则分页会作用到错误的查询上。3.4 退出登录与前端的token携带退出登录接口本身非常简单很多教程只写了一个空实现因为JWT是无状态的服务端不需要真的去删除什么。真正起作用的是前端把本地存储的token清掉后续请求不再携带。如果项目改成Redis存储token退出登录时就需要删除Redis中的token实现真正的强制下线。这引出一个重要概念JWT的无状态特性。服务端不保存会话意味着“退出”只能靠客户端丢弃token来实现。如果token在有效期内被别人截获仍然可以被使用。教学项目一般不会处理这个问题但做完整项目时要考虑把token存到Redis并在拦截器里校验这也是苍穹外卖后续Day里引入Redis的主要原因之一。前端请求时会在Axios拦截器里统一加请求头一般是Authorization: Bearer token。后端拦截器要做的是解析这个请求头验证token有效性解析失败直接返回401。这块Day1不一定实现完整版但至少要有这个意识登录接口逻辑通了只是第一步后续每个受保护接口都要过鉴权这一关。4. 本地上传图片Day1最容易糊弄但必须搞懂的功能4.1 为什么教学项目选择本地存储苍穹外卖的菜品、分类、套餐都涉及图片而Day1最常见的教学安排是在本地实现一个通用的图片上传接口。这个“本地上传”指的是把文件保存到服务器本机一个目录里返回一个可以访问的URL而不是上传到云服务商。为什么不在Day1直接接OSS之类的对象存储核心原因是接入SDK、申请密钥、配置Bucket这些步骤会严重分散初学精力。本地存储只需要控制文件路径和静态映射逻辑直白等把项目流程跑通后再替换成云存储只需改动上传实现类对Controller层几乎无感。还有一个现实因素Day1的图片上传接口会贯穿整个项目后面新增菜品、修改套餐、上传店铺logo都要复用。这一个接口能不能写得健硕直接决定后续的开发体验。4.2 后端上传接口的实现细节上传接口本质上是一个接收MultipartFile的POST接口。教学项目里通常长这样PostMapping(/upload) public ResultString upload(MultipartFile file) { // 1. 判断文件是否为空 if (file.isEmpty()) { return Result.error(文件不能为空); } // 2. 获取原始文件名提取后缀并生成新文件名 String originalFilename file.getOriginalFilename(); String extension originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID().toString() extension; // 3. 创建目录 String uploadDir D:/upload/; File dir new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } // 4. 保存文件 File target new File(uploadDir newFileName); file.transferTo(target); // 5. 返回访问路径 return Result.success(/upload/ newFileName); }这里有几个非常重要的细节第一个是文件名必须用UUID重命名不能用用户上传的原始文件名直接落盘。原因不只是防止中文乱码更重要的是防止路径穿越攻击如果文件名里带../拼接目录后就可能写到非预期位置。这是安全红线不是锦上添花。第二个是后缀必须从最后一个点之后截取因为文件名本身可能含多个点比如皮.蛋.粥.jpg。如果用indexOf(.)截出来的是.蛋.粥.jpg虽然大多数情况下不影响图片显示但放到生产环境非常不严谨。第三个是保存目录的写法最好从配置文件中读取而不是硬编码。使用配置文件后不同环境可以灵活切换。比如在application.yml里写sky: upload-dir: D:/upload/然后在代码里通过Value(${sky.upload-dir})注入。这样换机器部署时不用改代码只改配置。4.3 访问回显资源映射与Nginx静态目录文件保存成功只是第一步浏览器能不能访问到才是关键。Spring Boot默认只处理Controller路由和classpath:/static下的静态资源你保存到D:/upload/里的图片默认是无法通过http://localhost:8081/upload/xxx.jpg直接访问的。解决方式有两种。第一种是在Spring Boot里写资源映射配置Configuration public class WebMvcConfiguration implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file:D:/upload/); } }这种方案适合开发阶段快速联调但我们前面提到了前端和后端端口不同前端页面在8080后端接口在8081图片如果返回/upload/xxx.jpg前端直接拼出来的地址会变成http://localhost:8080/upload/xxx.jpg从而404。所以更稳妥的做法是把Nginx也配好让/upload/这个路径直接指到本地目录location /upload/ { alias D:/upload/; }这样前端通过http://localhost:8080/upload/xxx.jpg就能访问到图片。这就解释了为什么第2节里Nginx的配置要写两个location一个代理/api/到后端一个映射/upload/到本地文件各司其职又共用同一个域名和端口完美避开跨域和资源访问两个问题。4.4 上传环节的多发报错和排查思路本地上传这个功能学生问得最多的是这几个报错我整理了一套排查链路。第一个是Failed to parse multipart servlet request。这类问题通常是文件大小或请求大小超限Spring Boot默认单文件最大1MB超过就报错。可以在配置里调大spring: servlet: multipart: max-file-size: 10MB max-request-size: 20MB第二个是FileNotFoundException或目录不存在。这种情况十有八九是代码里只写了dir.mkdir()而不是dir.mkdirs()。mkdir()只能创建单层目录父目录不存在时会静默失败mkdirs()会递归创建所有父目录。顺手的地方建议先判断目录是否存在不存在就创建。第三个是图片上传成功但访问404。这时按顺序做三件事先手动在浏览器访问上传接口返回的URL再用curl -I看响应头最后确认Nginx是不是加载了最新配置如果改了conf没nginx -s reload一切白搭。第四个是Linux服务器上的路径问题。在Windows上写D:/upload/没问题但部署到Linux时换成了/home/ubuntu/upload/路径前缀不一样如果有人写死了Windows路径部署后必然挂。所以还是强调上传目录放到配置里不要写死在代码中。还有一个隐藏坑文件后缀也要做白名单校验不能只靠前端限制。有人会直接上传.jsp、.html这类文件如果服务器没有做静态目录的脚本执行权限就会产生安全风险。Day1先做到限制后缀和大小就够了后续接OSS时再考虑更完善的校验。5. Day1常见报错速查表与我的实操建议5.1 报错速查表我把Day1里出现频率最高的报错整理成了表格方便大家遇到问题时对照着查报错现象可能原因处理建议项目启动报Failed to configure a DataSource数据库密码错误、URL端口错误、数据库名不存在检查application.yml和本机MySQL登录提示“账号不存在”employee表没有初始数据确认sky.sql导入成功登录提示“密码错误”MD5盐值不一致或密码字段为空核对DigestUtils加密后的值和数据库值前端图片404没有配置Spring资源映射或Nginx静态目录检查/upload/对应的location配置跨域报错后端接口与前端不同源Nginx代理/api/或后端加CorsMappingtoken校验失败JWT密钥不一致、过期或请求头没带全检查jwt.yml配置和Axios拦截器这张表覆盖的是Day1绝大多数基础问题再往下就是Controller、Service、Mapper之间调用链没打通这种只能靠调试断点一步一步看值没有捷径。5.2 给初学者的三条经验第一代码写不完没关系但“跑的链路”必须完整。Day1哪怕只写了登录和上传两个接口你也应该把“前端发请求 → Nginx转发 → 后端处理 → 数据库查询 → 返回前端”这条链路完整走通一遍。很多学员卡住的本质不是不会写代码而是对链路里的某一环理解不到位。第二遇到报错先看控制台再看浏览器Network面板最后才去网上搜索。控制台和前端的Network面板能帮你快速定位到底是请求没发出去、被Nginx挡了还是后端抛异常了。拿着完整的报错堆栈去搜索效率远高于只贴一句“图片404”。第三重要环境配置要及时提交到自己的Git仓库并写注释。Day1的application.yml、nginx.conf这些文件后续会反复改动每次改动都可能引入新问题。养成“改配置后顺手提交并记录原因”的习惯后面项目做到Day10、Day15时你能少掉很多头发。5.3 我的一个实操习惯每次重启前先看端口占用这是我个人的建议也算是对前面所有排查经验的一个总结。Day1阶段你会频繁重启Spring Boot而经常出现的情况是上一次进程没被杀干净8081端口被占用新进程启动失败控制台却没直接报“端口被占用”而是报一堆奇怪的Bean创建错误。推荐一个固定动作netstat -ano | findstr 8081找到占用端口的PID后再用taskkill /PID 进程号 /F杀掉然后再启动项目。万事开头难Day1这套项目能顺顺当当跑通后面的菜品、订单模块都会快很多。反过来如果第一天就在环境上折腾一整天心态很容易崩。把我上面提到的版本对齐、数据库导入、Nginx代理、图片路径这几件事当成Day1的“四大关卡”逐个过节奏会舒服很多。