今天聊点实在的:苍穹外卖 day1到底该做哪些事。黑马这套“苍穹外卖”是很多 Java 学习者绕不开的实战项目,也是一套标准的 Spring Boot + Vue3 前后端分离应用。如果你刚拿到这个项目,大概率会先在环境配置和登录功能上花掉一整天,而本地上传图片这个功能虽然正式开放一般不在第一天的清单里,但它其实是检验你项目基础是否扎实的最佳试金石——好多同学就是栽在这里,上传成功了但浏览器访问 404。
这篇笔记我按自己实际做项目的顺序来写,不照搬教程目录,重点说清楚“为什么这样做”和“踩了哪些坑”。适合刚把 Java 基础过完、准备做第一个完整项目的读者,也适合已经在写苍穹外卖、卡在某个环节不知道怎么破的同学。学完这个 day1,你能跑通员工登录,并顺手把本地图片上传与访问机制打通,后面做菜品、套餐、店铺状态这些模块会省很多事。
1. 项目整体认知与技术选型拆解
1.1 苍穹外卖到底在练什么
先说结论:这不是一个普通的 CRUD 练习项目,而是一个包含管理端和用户端的完整外卖业务闭环。管理端相当于商家后台,要处理员工登录、分类管理、菜品管理、套餐管理、店铺营业状态、订单管理;用户端相当于 C 端小程序或 H5 页面,涉及微信登录、浏览菜品、购物车、下单支付、催单等流程。业务量虽然不大,但覆盖了一个真实项目绝大多数基础组件:权限校验、文件上传、数据缓存、分页查询、多表关联、前后端分离、接口联调。
第一天不需要把全部业务做完,但应该把项目的“骨架”立起来。也就是说,你要能清楚地说出:前端哪个页面调后端的哪个接口,后端请求经过哪些层,数据库的表和实体对应关系是什么。很多同学一做项目就闷头敲代码,结果做到第三天发现“员工登录时模糊查询那一段跟前面的逻辑完全割裂”,就是因为头一天没把整体数据流理顺。
另外,这个项目还有一个非常实用的学习点——本地上传图片。虽然它本身只是文件上传功能,没有太多业务含量,但它是整个项目第一个触动“静态资源映射”的知识点,也是后续所有上传图片模块的地基。把这个功能吃透,后面接 OSS、MinIO 都只是换一种存储实现而已。
1.2 技术栈选型背后的为什么
苍穹外卖的技术栈看起来中规中矩,其实是精心挑选过的,每一项都对应实际开发中的经典问题:
| 组件 | 选型 | 为什么是它 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x | 快速搭建、自动配置,社区资料最多,踩坑容易搜 |
| ORM | MyBatis | SQL 可控,复杂关联查询写起来直白,适合练习 SQL 能力 |
| 数据库 | MySQL | 主流关系型数据库,部署简单,配套管理工具多 |
| 缓存 | Redis | 用来做缓存和门店状态存储,为后续“性能优化”铺路 |
| 鉴权 | JWT + 拦截器 | 无状态、不占服务端内存,贴合前后端分离场景 |
| 前端 | Vue3 + Element Plus | 组件化开发,UI 成熟,大量后台管理系统用它 |
| 构建 | Maven | 依赖管理清晰,一个 pom.xml 就能搞定整个项目依赖 |
这里我想额外说一个问题:为什么不直接上 Spring Security?答案很简单,学习梯度。苍穹外卖第一天的核心是让你搞懂“前端带 token、后端验 token”这个机制本身。Spring Security 虽然强大,但过滤器链、自定义认证提供者这些概念对刚接触项目的人来说门槛太高,容易把注意力从业务逻辑拉走。用拦截器实现同一个效果,代码量少、思路直观,看到每一行代码在做什么,学习效率反而更高。
技术选型这件事,我一直跟身边人强调:不是越新越好,而是越稳越好。教程用 Spring Boot 2.7 你就不要硬上 3.x,教程用 MySQL 5.7 你也不要只因为新项目就装 8.0。并不是说新版本不能用,而是你在学习阶段遇到一个报错,检索到的答案可能都是老版本环境的,版本差异会让你排查半天。先把一套组合跑通,再谈升级。
2. day1 核心任务与前置环境准备
2.1 环境版本清单与安装建议
我见过太多人第一天就卡在环境上,不是 JDK 版本不兼容,就是 MySQL 字符集没设置对。这里给出一份我实测的版本清单,照着装基本不会出大问题:
| 软件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | Spring Boot 2.7 都能跑,别装 JDK 21 |
| Maven | 3.6.x 或 3.8.x | 3.9 也行,但 settings.xml 别乱改 |
| MySQL | 5.7 或 8.0 | 安装时选 utf8mb4 字符集 |
| Redis | 6.x | Windows 下推荐用安装版,别用太旧的 3.x |
| Node.js | 16 或 18 | 跑 Vue3 前端项目用的 |
| IDE | IntelliJ IDEA | 社区版够用,装 Lombok 插件 |
关键提醒:JDK 版本一定要在开项目之前确认。有些教程默认你用 JDK 8,如果你电脑上同时装了 JDK 17,Spring Boot 2.7 虽然能跑,但某些老版本 Lombok 会直接报IncompatibleClassChangeError,排查起来非常挠头。我建议学习环境尽量隔离,用一个专门给苍穹外卖准备的 JDK 8/11 版本。
MySQL 这里多说一句:导入sky_take_out.sql脚本时,记得先创建数据库再导入,并且设置好默认字符集:
CREATE DATABASE IF NOT EXISTS sky_take_out DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;不使用 utf8mb4 的话,后面菜品名称里出现“靓汤”“brunch”这类字符时,数据库会报Incorrect string value错误。
Redis 在第一天就启动着,不需要专门写代码。因为后面 JWT 令牌校验、缓存功能都依赖它,如果你暂时用不到缓存,至少也要保证 Redis 服务是通的,否则项目启动检查 Redis 连接时可能报错。Windows 用户我建议直接用压缩包版本,跑一个redis-server.exe就能用,别只凭记忆去下载“绿色版”,容易找到带广告的捆绑包。
2.2 后端工程初始化与配置文件
新建 Spring Boot 工程时,不用迷信 start.spring.io 的 Latest 版本,直接选 Spring Boot 2.7.x。Dependencies 勾这几个核心依赖:
- Spring Web
- MyBatis Framework
- MySQL Driver
- Spring Data Redis
- Lombok
- Validation
JWT 相关包在 start.spring.io 里没有,需要自己在pom.xml里引入jjwt或java-jwt。项目里常用的是io.jsonwebtoken:jjwt:0.9.1,这个版本与新 JDK 有一些兼容问题,所以前面强调 JDK 1.8/11 是稳妥的。
包结构我建议这样建,跟后续教程保持一致:
com.sky ├── common(通用类:结果封装、常量、全局异常) ├── config(配置类:WebMvc、拦截器注册) ├── controller(接口层) ├── entity(数据库实体) ├── mapper(MyBatis接口) ├── pojo(DTO、VO) ├── service(业务层) ├── interceptor(自定义拦截器) └── utils(JwtUtil、AliOssUtil等工具)application.yml是整个 day1 的关键,很多启动失败都是从这里开始的。我贴一份实际跑通的配置:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/sky_take_out?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 servlet: multipart: max-file-size: 5MB max-request-size: 20MB mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.sky.entity sky: jwt: secret-key: sky-take-out-secret ttl: 7200000 token-name: token file: upload-path: D:/skyUpload/几个细节你拿去就能用,不用再翻别人的配置:
第一,数据库连接串里的serverTimezone=Asia/Shanghai必须带,不然 MySQL 8.x 会给你抛时区错误。第二,mybatis.mapper-locations写成classpath:mapper/*.xml,确保 Mapper 接口和 XML 文件能对上,否则启动不报错,但一执行查询就会报Invalid bound statement。第三,file.upload-path是给图片上传用的,Windows 上建议写D:/skyUpload/,Linux 上写/usr/local/skyUpload/,路径末尾的斜杠一定要带,不带会导致拼接文件路径时目录名和文件名粘在一起。
3. 登录功能与“本地上传图片”的实操要点
3.1 员工登录全流程实现
day1 最核心的业务功能是员工登录,逻辑并不复杂:前端把账号密码交到后端,后端查库、比对密码,校验员工状态,然后签发一个 JWT 令牌返回给前端,前端把令牌存在本地,后续请求全部带上。
先写接收参数的 DTO:
@Data public class LoginDTO { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password; }Controller 不需要在方法里写一堆业务逻辑,只负责收参和返回:
@RestController @RequestMapping("/employee") public class EmployeeController { @Autowired private EmployeeService employeeService; @PostMapping("/login") public Result<EmployeeLoginVO> login(@RequestBody LoginDTO loginDTO) { Employee employee = employeeService.login(loginDTO); // 生成JWT令牌 Map<String, Object> claims = new HashMap<>(); claims.put("empId", employee.getId()); String jwt = JwtUtil.createJWT( jwtProperties.getSecretKey(), jwtProperties.getTtl(), claims); // 封装VO返回 EmployeeLoginVO employeeLoginVO = EmployeeLoginVO.builder() .id(employee.getId()) .userName(employee.getUsername()) .name(employee.getName()) .token(jwt) .build(); return Result.success(employeeLoginVO); } }Service 里的核心判断是这样的:
public Employee login(LoginDTO loginDTO) { // 1. 根据用户名查数据库 Employee employee = employeeMapper.getByUsername(loginDTO.getUsername()); // 2. 用户不存在 if (employee == null) { throw new AccountNotFoundException("用户名不存在"); } // 3. 密码核对,需要做MD5后再比对 String md5Password = DigestUtils.md5DigestAsHex(loginDTO.getPassword().getBytes()); if (!md5Password.equals(employee.getPassword())) { throw new PasswordErrorException("密码错误"); } // 4. 账号状态校验 if (employee.getStatus() == 0) { throw new AccountLockedException("账号已禁用"); } return employee; }这里有两个特别值得注意的细节。第一个是密码绝不能明文存储在数据库里,就算做练习也一样。项目里用 MD5 加密码再落库,虽然 MD5 不算强加密,但对于这个学习场景足够说明问题:数据表里永远不存真实密码。第二个是异常的处理方式。项目里通常有全局异常处理器@RestControllerAdvice+@ExceptionHandler,你抛出业务异常后,由全局处理器统一转成 JSON 返回给前端,Controller 里不要自己去 try-catch,否则每个接口都要写一套异常判断,又乱又容易漏。
登录还有一种“看似成功但实际没通过校验”的经典情况:前端请求时根本没带 token,或者 token 过期了。这个靠拦截器解决。我写一个普通的拦截器,核心逻辑是:
public class JwtTokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 跨域预检请求直接放行 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } // 从请求头取token String token = request.getHeader("token"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims = JwtUtil.parseJWT(jwtProperties.getSecretKey(), token); Long empId = Long.valueOf(claims.get("empId").toString()); // 存入当前线程上下文,后续业务里可以直接取当前操作人 BaseContext.setCurrentId(empId); return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦截器注册在 WebMvc 配置类里,注意要把登录接口排除掉:
@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Autowired private JwtTokenInterceptor jwtTokenInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtTokenInterceptor) .addPathPatterns("/**") .excludePathPatterns("/employee/login"); } }如果你在 day1 就做到了这一步,那整个登录链路你已经掌握了:前端请求携带 token、后端拦截器校验 token、业务线程通过 BaseContext 拿到当前登录用户 ID。这个模式后面几乎所有接口都会复用。
3.2 本地上传图片功能实现与静态资源映射
“苍穹外卖本地上传图片”这个热搜词,说白了就是项目中把图片存到服务器本地磁盘,而不是直接丢给云服务商。很多同学在这块报 404,关键是没有理解 Spring Boot 的静态资源映射是怎么工作的。
图片上传的本质是三步:接收文件、保存到磁盘、返回一个可访问的 URL。先看上传接口:
@RestController @RequestMapping("/common") public class CommonController { @Value("${file.upload-path}") private String uploadPath; @PostMapping("/upload") public Result<String> upload(MultipartFile file) throws IOException { // 1. 判断是否为空 if (file == null || file.isEmpty()) { return Result.error("文件为空"); } // 2. 获取原始文件名,截取扩展名 String originalFilename = file.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); // 3. 生成新文件名 String newFileName = UUID.randomUUID() + extension; // 4. 检查目录是否存在,不存在则创建 File dir = new File(uploadPath); if (!dir.exists()) { dir.mkdirs(); } // 5. 保存文件 File targetFile = new File(uploadPath + newFileName); file.transferTo(targetFile); // 6. 返回浏览器可访问的相对地址 return Result.success("/upload/" + newFileName); } }这里有几个细节我想重点解释一下。
为什么要用UUID.randomUUID()重命名?因为直接返回用户上传的原始文件名,会有两个问题:一是文件名包含中文时容易出现编码问题,二是不同用户如果上传同名文件会互相覆盖。UUID重命名后虽然文件名不可读,但换来的是全局唯一,安全性和稳定性都更高。
为什么接口返回/upload/xxx.jpg而不是完整路径D:/skyUpload/xxx.jpg?因为前端需要的只是一个相对 URL,这个 URL 通过 Spring MVC 的静态资源映射就能映射到磁盘路径上。把真实磁盘路径直接暴露给前端是坏味道,一旦拆分成多台服务器,这种写法立刻穿帮。
真正让访问不 404 的关键是资源映射配置:
@Configuration public class WebMvcConfiguration implements WebMvcConfigurer { @Value("${file.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }addResourceHandler("/upload/**")的意思是:当浏览器请求路径以/upload/开头时,Spring Boot 会去file:后面的磁盘路径下找文件。比如你访问http://localhost:8080/upload/abc.jpg,实际读取的是D:/skyUpload/abc.jpg。
注意,addResourceLocations必须写file:前缀,这是本地文件协议的标志。如果你不加file:,Spring 会以为你要从 classpath 或 Servlet 容器上下文里找资源,结果永远是 404。
另外还有两处容易坑人的地方。第一,上传目录的权限。Linux 环境下如果目录创建失败,程序不会报错,但保存文件时会有FileNotFoundException,这时候要检查运行 Java 进程的用户对目标目录有没有写权限。第二,配置文件里的upload-path末尾必须带斜杠。如果写成D:/skyUpload,拼接new File(uploadPath + newFileName)时会变成D:/skyUploadabc.jpg,直接报“系统找不到指定的路径”。
4. 常见问题排查与避坑记录
4.1 day1 启动与登录过程中的高频异常
我按照自己实际带项目时遇到的高频问题整理了一张表,每个问题都是真实场景,不是教科书上的理论情况。
端口被占用
8080被其他程序占用的概率很高。排查时先看日志里的错误信息,如果是Port 8080 was already in use,一种方式是改server.port,另一种方式是杀掉占用进程。Windows 下可以执行netstat -ano | findstr 8080找到 PID,再taskkill /PID <pid> /F强制结束。如果是 Redis 的 6379 端口被占用,同理处理。
MySQL 连不上:Unknown database 或 Access denied
这类报错多半是数据库和用户权限问题。先确认sky_take_out数据库真的创建了,再确认application.yml里的用户名密码和本地 MySQL 一致。如果你是 MySQL 8.0,连接驱动要用com.mysql.cj.jdbc.Driver,如果你的 Spring Boot 版本较老,默认还是旧的com.mysql.jdbc.Driver,也要手动改一下。
Redis 连接失败
Redis 在 Windows 下启动之后不会显示日志窗口,很多人以为没启动成功。其实只要能看到redis-server.exe进程在运行,并且配置文件里的host、port跟实际一致就行。如果设置了 Redis 密码,application.yml里还要配置spring.data.redis.password。
Invalid bound statement (not found)
这句话几乎每个用 MyBatis 的人都会见到。绝大多数原因是 Mapper 接口和 XML 文件没绑定上。检查两点:XML 的namespace必须写接口全限定名;application.yml中mapper-locations必须扫描到 XML 所在目录。另外,XML 文件要放在src/main/resources/mapper/下,而不是src/main/java。
前端接口请求失败:代理没有配好
如果前端运行后,登录按钮一提交就报 404 或 405,很可能是前端代理问题。Vue 项目里检查vite.config.js中的代理配置:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }很多同学后端的 Controller 路径是/employee/login,前端请求却是/api/employee/login,中间多了一层api前缀。这就需要代理配置 rewrite 把/api去掉,否则后端匹配不到路由。
4.2 本地上传图片相关的排查
接下来单独把“本地上传图片”的坑拉出来说,因为它属于“看起来很简单、实际翻车率极高”的功能。我见过不少同学卡在这里一两个小时,翻来覆去不知道哪出了问题。
上传接口返回 401
如果你上传图片时发现请求被拦截了,先去检查拦截器配置。很多人只排除了/employee/login,却忘了图片上传走的是/common/upload,它被拦下来了。解决办法是在excludePathPatterns里加上/common/upload。不过这里要留个心眼:如果下一步你要做用户端,用户端的某些接口也走同一个拦截器的话,排除路径要考虑清楚,别把不该放行的也放行了。
上传成功但访问不到图片
这个是最隐蔽的问题。文件明明保存到了本地目录,但浏览器访问http://localhost:8080/upload/xxx.jpg时返回 404。遇到这种情况,我建议按照以下顺序排查:
- 访问路径是否真的带上了
/upload前缀?你在接口里返回的是/upload/文件名,前端有没有把域名拼接正确。 - 资源映射配置里
file:后面的路径是否和application.yml一致?有人把D:/skyUpload/写成了D://skyUpload/,或者漏了末尾斜杠。 - 文件是否真的保存到了配置目录?如果 IDEA 工作目录和磁盘上路径不一致,可能出现“程序写到了 A 目录,你手动跑去看 B 目录”的情况。
这里我再给一个排查小技巧:直接在浏览器访问一个你确定存在的文件,比如手工在uploadPath目录下新建一个test.jpg,再去访问/upload/test.jpg。如果手工文件能显示而程序上传的显示不了,问题在保存路径;如果手工文件也显示不了,问题在资源映射。
文件名中文乱码
图片名称如果是中文,访问时 URL 编码不一致会导致 404。这个问题本质上就是上传时没有重命名造成的连锁反应。我强烈建议按项目里的规范来:统一用UUID重命名,原始文件名只用来截取扩展名,不做任何业务依赖。这个习惯放到以后接云存储时同样适用。
上传大文件报 MaxUploadSizeExceededException
Spring Boot 默认上传文件大小上限一般是 1MB,超过就会抛异常。如果你是图片上传,问题不大,但如果后续菜单里要传产品详情图片,就可能超限。在application.yml中调整:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 50MB注意max-file-size是单个文件大小,max-request-size是一次请求最多允许的总大小。前者比后者大的时候,多文件上传依然会失败。
Linux 上出现 FileNotFoundException
本地 Windows 跑得好好的,部署到 Linux 就上传失败,十有八九是目录权限问题。你可以用ls -ld /usr/local/skyUpload查看目录权限,如果没有读写权限,要么用chmod 755调权限,要么换目录路径并赋予当前运行用户权限。另外上传目录不要太深,避免路径中包含不存在的一级目录导致mkdirs()没生效。
5. 个人实操体会与后续扩展
我自己给项目做 day1 时有个习惯:不止做登录,而是把图片上传也一起跑通,哪怕后面还没有真正用到图片的功能。因为本地上传图片涉及的知识点非常独立,而且它是后面很多模块的公共基础。你在第一天硬着头皮把这块打通,后面做分类页面、菜品页面时,几乎不需要再重新思考上传逻辑,只是把 URL 存到数据库而已。反过来,如果拖到后面再做,往往会被一个“上传失败”卡住整个开发节奏。
再分享一个我踩过多次的坑:环境版本一定要锁定,不要顺手升级。第一天搭建项目时,大家很爱顺手把 Spring Boot 升级到最新、JDK 换到 17、Redis 换到 7,结果一个小毛病排查半天,最后发现是版本不兼容。学习阶段讲究的是稳定复现,你只要锁定一套能跑通的组合,其它版本的差异等以后工作里再熟悉也不迟。
最后说说这块还能怎么扩。本地上传图片解决的是“开发环境够用”的问题,部署到线上时,通常会把这套逻辑替换成 OSS 或 MinIO。你在 day1 写的CommonController.upload方法,只要把存储部分换个实现,就能平滑迁移到云存储。所以前面把接口返回相对路径、用UUID重命名这些习惯养好,后面接云存储会非常顺畅。苍穹外卖做完之后,我建议你花点时间把上传逻辑抽象成接口,再做一版对象存储的实现,这对理解项目架构的演进会有很大帮助。