写这个项目的人很多,真正把整条链路走完的不多。最近带完一期《苍穹外卖》实战训练,不少同学从 Spring Boot 开发一路做到微信小程序部署上线,过程中踩的坑几乎都一样。今天把这套 Java 前后端分离项目从零到部署的关键环节一次性捋清楚,给正准备照着做的朋友省点时间。
1. 项目整体认知与设计拆解
1.1 项目到底在做什么
苍穹外卖是一套典型的外卖点餐系统,整个产品分成三个端:用户端的微信小程序、管理端的 Web 后台、以及为两端提供接口的 Java 后端服务。用户在小程序里浏览菜品、加购物车、下单支付、查看历史订单;管理员在后台维护员工账号、菜品分类、菜品与套餐信息,处理用户订单,查看营业数据。
听起来像是个常见的业务系统,但这个项目设计的巧妙之处在于:它把真实企业应用中最常见的功能模块几乎全部覆盖了一遍。员工管理涉及账号和角色权限,菜品与分类涉及多层级数据关系,购物车要处理临时数据存储,订单系统要理解状态机流转,支付环节涉及第三方接口对接,营业统计要求聚合查询,来单提醒还要用到实时通信。做一遍这个项目,等于把 Java 后端开发的主流知识点按业务线完整串了一遍。
项目采用前后端分离架构,这也是目前企业开发的主流模式。后端只提供 RESTful API,不关心页面长什么样;前端各自独立开发、独立部署,通过 HTTP 接口对接数据。对学习来说,这种架构能让你清楚看到数据是怎么从数据库一路流到小程序界面的,调试起来边界也清晰。
1.2 为什么值得花时间做
很多初学者问我的第一个问题是:我跟着教程敲一遍代码,到底学到了什么?我的回答是:关键看你做完之后能讲清楚多少东西。
苍穹外卖这个项目最大的优势在于业务场景真实、完整。外卖点餐的流程大家都熟悉,所以业务逻辑不用花精力去理解,可以把注意力集中在技术实现上。订单状态从待付款到已完成、从用户取消到商家拒单,这些状态设计在企业系统中非常有代表性。学完这个,你再去看电商、预约、配送类系统的订单模块,会发现套路都是相通的。
其次是它的技术栈覆盖面很广。不光是 CRUD,还有 JWT 登录鉴权、Redis 缓存、定时任务、WebSocket 实时推送、第三方支付回调、服务器部署。这些能力单独拎出来任何一项都是 Java 面试中的高频考点。把它们放在一个项目里融会贯通,面试时能讲出的细节深度和只背八股文完全不一样。
对于准备找 Java 开发工作的朋友,我的建议是:这个项目可以作为简历上的主项目来写。它的业务复杂度比图书管理系统、学生管理系统高一个档次,但又没有微服务那么重,非常适合用来展示你掌握了完整的企业级开发流程。
1.3 技术选型背后的考量
后端用的是 Spring Boot 2.x + Spring MVC + MyBatis + MySQL + Redis,管理员端前端是 Vue + Element UI,用户端是微信小程序原生开发。这个选型组合很多人觉得“不够高级”,但其实每个选择都有它实际的考量。
Spring Boot 不用多说,现在是 Java 后端的事实标准。选 MyBatis 而不是 MyBatis Plus,主要是为了让新手把 SQL 基本功练扎实。项目里的多表联查、动态 SQL、批量插入都是真实业务场景下会遇到的写法。如果你直接上手 MyBatis Plus,虽然 CRUD 省事了,但对 SQL 的理解反而会变浅。MyBatis 原生写法的语法和坑都见过之后,再切到 Plus 也就是几分钟的事。
Redis 在这个项目里承担了两类职责:一类是缓存热点数据,比如菜品分类和套餐信息;另一类是存储临时数据,比如购物车。用 Redis 存购物车是很典型的场景,因为购物车不需要长期持久化,而且 Redis 的 Hash 结构天然适合保存“用户 ID 到购物车条目”这样的映射关系。
微信小程序端选原生开发,原因也简单:教程受众大多是 Java 方向,前端基础相对薄弱。原生小程序语法接近 Vue,学起来门槛低,而且不需要额外构建工具。管理端用 Vue 是因为项目本身要演示前后端分离,而 Vue 是当前最容易上手的企业级前端框架。
2. 核心功能模块与数据库设计
2.1 功能模块划分
整个系统的功能可以按角色分成两大块。用户端小程序包含微信登录、浏览菜品、购物车管理、下单支付、查看订单、地址薄管理这几个核心模块。管理端后台包含员工登录、员工管理、分类管理、菜品管理、套餐管理、订单管理、营业数据统计。
两个端共用同一个后端服务,但接口路径按前缀区分。管理端接口以 /admin 开头,用户端接口以 /user 开头,后端通过不同模块的 Controller 分别处理。这样做的好处是权限控制非常清晰:管理端走管理员拦截器验证 JWT,用户端走用户拦截器验证 JWT,两条链路互不干扰。
模块划分上有一点值得注意:员工管理和用户管理是两个完全独立的体系。员工属于企业内部账号,需要后台创建;用户是从微信小程序登录时自动注册的。这两个体系的数据表也完全分开,不要试图用同一张表来存储不同角色的账号。
2.2 数据库表设计思路
数据库是这个项目的地基。核心表包括:员工表(employee)、分类表(category)、菜品表(dish)、菜品口味表(dish_flavor)、套餐表(setmeal)、套餐菜品关系表(setmeal_dish)、购物车表(shopping_cart)、地址薄表(address_book)、订单表(orders)、订单明细表(order_detail)。
菜品和分类是最典型的一对多关系。菜品必须归属于某个分类,分类下可以包含多个菜品。菜品又分为普通菜品和套餐菜品两种形态,套餐是多个菜品的组合,所以需要一张中间表 setmeal_dish 来维护套餐与菜品的多对多关系。中间表里除了两个外键,还冗余存了一份菜品名称和价格,这个设计是故意的——套餐的历史快照不能受菜品后续修改影响。
订单表与订单明细表的关系是很多初学者容易搞混的地方。订单一主一从,主表记录整体信息(订单号、金额、状态、用户 ID、地址、下单时间),从表记录具体买了哪些菜品。外键关联用的是 order_id,但菜品相关的名称、价格、口味信息全部冗余在明细表里。为什么?因为订单是交易快照,用户下单之后菜品改名了、价格调整了,订单里的历史数据都不能跟着变。这一点面试的时候经常被问到,能讲明白说明你真的理解了表设计的意义。
2.3 登录鉴权与 JWT
管理端员工登录的逻辑不难:前端把账号密码传给后端,后端查 employee 表,校验密码和账号状态,通过后生成 JWT 返回给前端。后续所有请求在请求头里带上这个 token,后端拦截器统一校验。
项目里用 ThreadLocal 来保存当前登录员工的 ID,这个设计很多人第一次见到会疑惑。其实 ThreadLocal 的语义是“每个线程独享变量”,由于一次 HTTP 请求从头到尾都在同一个线程里执行,所以拦截器里放入的登录用户信息,在 Controller 层可以随时取出来,不需要每个方法都加一个 user 参数。代码简洁,职责也清晰。不过要记得在请求结束时清理 ThreadLocal,否则线程池复用线程时可能造成数据串号。
用户端的登录流程稍微特殊一点。小程序端调用 wx.login() 拿到一个临时 code,后端拿到 code 后调用微信的接口换取 openid。openid 是用户在微信体系下的唯一标识,用它可以判断用户是新用户还是老用户:新用户自动注册,老用户直接登录。整个过程中小程序端并不需要输入用户名密码,用户体验非常顺滑。
JWT 的核心特点是服务端无状态。签发之后,服务端不保存会话信息,只靠签名验证 token 的真实性。配置上要重点关注密钥的保管和过期时间设置,密钥不要硬编码在代码里,过期时间也不宜设得太长,一般 7 天到 30 天比较合理。
3. 后端开发实操要点
3.1 统一返回结果与全局异常
后端接口的设计规范决定了前后端对接的效率。项目里定义了一个通用的返回类 Result<T>,包含三个字段:code(状态码)、msg(提示信息)、data(业务数据)。所有 Controller 接口都返回这个类型,成功时调用 Result.success(data),失败时调用 Result.error(msg)。
这样做之后,前端只需要处理一种固定的数据格式,判断成功只看 code 字段即可。比起每个接口返回结构都不一样,这种方式显著降低了联调成本。
全局异常处理是另一个容易被忽略的点。开发时常见的做法是每个业务方法自己 try-catch,然后各自返回错误信息。这样做的后果是代码里到处是重复的错误处理逻辑,而且一旦漏了某个异常,用户就会看到一堆看不懂的报错。
正确的做法是自定义一个业务异常类 BusinessException,业务代码里只需 throw new BusinessException("xxx"),然后编写一个用 @RestControllerAdvice 注解的全局异常处理器。处理器里分别处理自定义异常、参数校验异常和未知异常,统一返回 Result.error(msg)。注意未知异常不要在返回信息里暴露堆栈和 SQL 细节,日志里记录完整堆栈就行,返回给前端的信息保持简洁。
3.2 Redis 缓存与数据一致性
外卖系统的菜品分类、套餐信息属于高频读取、低频变化的数据。如果不加缓存,用户每次打开小程序都要去数据库查一遍,在高并发场景下数据库压力会非常大。项目里用 Redis 缓存这些热点数据,第一次查询时从数据库加载并放入缓存,后续请求直接命中缓存返回。
缓存带来的副作用是数据一致性问题。后台修改了菜品信息后,缓存里还是旧数据,用户看到的仍旧是修改前的样子。解决方案也很直接:在增删改操作执行成功后,主动清理对应的缓存 key,让下一次查询重新从数据库加载。这种“先改库,再删缓存”的策略在大多数业务场景下是够用的,比引入消息队列异步双删要简单得多。
还有一个值得注意的点是缓存穿透。当用户查询一个不存在的菜品 ID 时,每次请求都会绕过缓存直接打数据库。解决思路是:即使查询结果为空,也在缓存里存一个空对象,并设置较短的过期时间。这样恶意请求再多,后端数据库也不会被打穿。
3.3 菜品、购物车与订单核心逻辑
菜品接口的难点不在 CRUD 本身,而在于业务规则的体现。比如起售中的菜品不能被修改状态、删除分类时如果分类下还有菜品则不能删除、删除菜品时要检查它是否关联了套餐。这些规则每一个都要在接口里显式判断并抛出明确异常,而不是等到数据库报外键错误。
购物车模块用 Redis 存储,key 的设计是用户 ID,field 是菜品 ID,value 是购物车条目。这样用户添加菜品、修改数量、清空购物车都只需要操作 Redis 的 Hash 结构,性能好且不需要建表。下单成功后要记得清空对应用户的购物车。
订单模块是整个项目里状态最复杂的部分。订单状态大致经历:待付款、待接单、待配送、配送中、已完成,另外还有已取消和已退款。接单、拒单、派送、完成、取消,每个操作都要校验当前状态是否合法。推荐在代码里定义一个常量类或者枚举类来管理状态值,不要使用魔法数字。状态变更时用 update 语句带上 WHERE status = 当前状态 条件,既防止重复操作,也天然解决了并发问题。
3.4 定时任务与 WebSocket 推送
项目中有一个非常典型的场景:用户下单后如果超过 15 分钟未支付,订单需要自动取消。这个功能用 Spring Task 的定时任务实现,配置一个 cron 表达式,每分钟执行一次查询,把所有超时未支付订单批量更新为已取消,同时恢复库存。
定时任务里要特别留意两个问题:一是任务执行时间不要跟高峰期重叠,避免对数据库造成额外压力;二是多实例部署时同一个任务会被多个实例重复执行,需要引入分布式锁或者保证任务幂等。教学项目虽然是单机部署,但理解这个风险对以后工作很有帮助。
来单提醒和订单状态变更推送用的是 WebSocket。管理端登录后建立 WebSocket 连接,用户下单时后端主动向管理端推送一条“您有新的订单”消息。技术实现上,后端起一个 WebSocket 服务端,管理端前端通过原生 WebSocket 客户端连接,连接成功后保存 session,推送时遍历 session 发送消息。要注意心跳检测和断线重连机制,否则连接长时间空闲会被服务端断开。
4. 微信小程序端对接实践
4.1 用户端登录流程
小程序端登录是整个项目前后端对接的第一步,流程可以总结为三步:小程序调 wx.login() 获取临时 code,将 code 发送到后端,后端拿到 code 向微信接口换取 openid 并完成注册登录,返回 JWT。小程序端拿到 JWT 后存储在本地缓存中,后续所有请求在请求头里带上 token 即可。
开发中最容易踩的坑有三个。第一个是域名校验问题,微信小程序要求所有请求地址必须是 HTTPS 且域名已备案,开发阶段可以在开发者工具里勾选“不校验合法域名”,但真机预览和线上版本不会放过你。第二个是 wx.getUserProfile 和获取手机号接口的权限调整,微信官方对用户隐私信息的获取限制越来越严格,头像昵称现在推荐使用 button 组件的 open-type="chooseAvatar" 方式获取。第三个是 code 的时效性,code 只能用一次且有效期很短,拿到后要立即发给后端,不要做任何中间处理。
4.2 接口对接与数据渲染
小程序的网络请求建议封装成一个公共方法,统一处理 baseURL、请求头注入、HTTP 状态码和业务状态码的拦截。比如当后端返回 code 为 0 时业务成功,返回其他值时弹出错误提示,遇到 401 时清理本地登录态并跳转登录页。这样每个页面调用时只需要关心业务数据处理,不用重复编写错误处理逻辑。
菜品列表页要注意分页加载与下拉刷新的配合。小程序端的 onReachBottom 触发加载下一页,onPullDownRefresh 触发重新加载第一页。要维护好当前页码和是否还有更多数据的标志位,避免重复请求。
自定义导航栏这个细节很多新手会忽略。如果项目使用了自定义顶部导航栏,不同机型的胶囊按钮位置和状态栏高度都不一样,需要调用 wx.getMenuButtonBoundingClientRect() 获取胶囊位置,结合 wx.getSystemInfoSync() 拿到状态栏高度,动态计算导航栏高度。否则会出现 iPhone 上正常,Android 机上按钮被刘海遮挡的问题。
4.3 微信支付对接
支付模块是完整商业项目绕不开的环节,但调试成本较高。苍穹外卖的支付流程是标准的微信支付小程序支付:后端调用微信支付统一下单接口,拿到支付参数返回给小程序;小程序调 wx.requestPayment 拉起收银台;用户输入密码完成支付;微信服务器向后端回调地址发送支付结果通知;后端校验签名后更新订单状态。
支付回调的验签是安全关键点,必须校验签名和后端订单金额是否一致,防止伪造回调。教学环境没有真实商户号时,可以先用模拟支付工具替代,但业务流程要保持完整,方便将来接入真实支付时改动最小。
5. 部署上线与环境配置
5.1 服务器准备考查些什么
部署之前先把服务器的需求盘清楚。一个最简单的线上环境至少需要:一台云服务器、一个已备案的域名、以及 HTTPS 证书。
服务器配置方面,2 核 4G 内存是起步,能跑一个不错的演示项目。服务器上要同时运行 MySQL、Redis、Java 应用和 Nginx,内存分配要提前规划好。带宽建议 3M 起步,小程序端图片资源多时带宽不够页面会卡。操作系统选 CentOS 7 或 Ubuntu 20.04 都行,关键在于你自己熟悉哪种。
安全组配置要遵循最小开放原则:80 和 443 对外开放,供 HTTP/HTTPS 访问;3306(MySQL)和 6379(Redis)绝对不能对外开放,应用通过内网 IP 访问这两个服务即可。如果直接把数据库端口暴露到公网,扫描机器几分钟就能找上门。
5.2 Docker 容器化部署
用 Docker 部署最大的好处是环境一致性和可复现性。本地能跑,线上就一定能跑,不需要在服务器上手工装 JDK、配置环境变量。
后端服务的 Dockerfile 写起来很简单:先通过 Maven 打 jar 包,再用一个基础 OpenJDK 镜像把 jar 包打进去。关键是启动参数里要设置 JVM 内存,比如 -Xms512m -Xmx512m。容器内存和 JVM 堆内存要匹配,容器限制 1G 时堆内存却设为 1G,很容易触发内存溢出。我见过很多同学在部署阶段报 java.lang.OutOfMemoryError,基本都是这个原因。
MySQL 和 Redis 不建议直接写在应用镜像里,而是通过 docker-compose 一起编排。docker-compose.yml 里定义四个服务:mysql、redis、app、nginx。MySQL 和 Redis 要挂载数据卷,否则重新创建容器数据就全丢了。时区统一设置成 Asia/Shanghai,不然日志时间和订单时间会比北京时间少 8 个小时。
5.3 HTTPS 证书与 Nginx 反向转发
小程序线上环境强制要求 HTTPS,这个没有讨价还价的余地。申请 HTTPS 证书后,配置 Nginx 监听 443 端口,加载证书,并把请求转发到后端的 Java 服务。
Nginx 在这里承担两个职责:一是托管管理端和用户端的前端静态文件,二是反向转发 API 请求到 Spring Boot 应用。配置反向转发时要注意 proxy_pass 后面的路径拼接规则。比如 location /admin 对应 proxy_pass http://127.0.0.1:8080 和 proxy_pass http://127.0.0.1:8080/,这两个写法结果完全不同,前者会保留 /admin 前缀,后者会去掉。搞错了就会出现前端请求到了后端,但接口全部 404。
同时建议给静态资源配置缓存过期时间,图片、JS、CSS 这些资源设置 Cache-Control 可以减少后端压力。HTTP/2 也值得开,多个并发请求的加载速度会比 HTTP/1.1 明显提升。
5.4 部署流程的整体梳理
整个部署流程可以按这个顺序走:先解析域名,确认能 ping 通;然后安装 Docker 和 docker-compose;接下来启动 MySQL 和 Redis 容器,导入数据库初始化脚本;构建后端镜像并启动,用 curl 测试后端接口是否正常;再构建前端项目,把 dist 目录上传到服务器;最后配置 Nginx 转发规则,申请并加载 HTTPS 证书,启动 Nginx。
一步步验证是很关键的操作习惯。不要等服务全部启动了再一起排查问题,那样定位成本会非常高。后端起来就先测后端,前端起来就先看静态资源能不能访问,Nginx 配置好了再看反向转发通不通。每一步验证通过再进行下一步,整个部署过程其实很顺利。
6. 常见问题与排查实录
6.1 高频问题速查表
| 常见问题 | 可能原因 | 排查思路 |
|---|---|---|
| 本地接口正常,线上请求 404 | Nginx 反向转发路径拼接错误 | 检查 proxy_pass 是否带斜杠,curl 直接访问后端接口对比 |
| 小程序真机请求失败 | 未配置合法域名、域名未备案、未开启 HTTPS | 小程序后台配置 request 合法域名,确认证书有效 |
| 订单时间显示差 8 小时 | MySQL 或 JVM 时区不是东八区 | 连接串加 serverTimezone=Asia/Shanghai,容器设置环境变量 TZ |
| 用户看到旧菜品数据 | 修改数据后没有清理 Redis 缓存 | 对应增删改接口补充删除缓存逻辑 |
| 应用启动后内存溢出 | JVM 堆内存设置超过容器内存 | 查看容器内存 limit,调小 -Xmx 或调大容器内存 |
| 定时任务不执行 | cron 表达式错误或时区问题 | 先看日志,确认任务是否正常启动,再检查服务时间 |
| 小程序登录报错 | code 使用一次后失效、接口调用慢导致过期 | 确认 code 是否只被消费一次,检查请求时序 |
6.2 两个印象深刻的坑
讲一个很多学员都遇到过的场景:开发环境本地跑得好好的,管理端页面也能正常打开,部署到服务器之后,用户在微信小程序里下单,管理端却一直收不到来单提醒。排查了半天,后端日志能正常打印“收到新订单”,说明接口是通的,问题出在 WebSocket 连接没有建立成功。
最后发现原因其实很简单:Nginx 配置里没有为 WebSocket 做升级协议的支持。WebSocket 握手时要带上 Upgrade 和 Connection 头,Nginx 默认配置不会自动转发这些头信息,需要在反向转发配置里显式声明,否则浏览器和服务器之间的长连接根本建立不起来。这个坑在本地开发时不会出现,因为本地是直接连 WebSocket 服务端,只有到了线上通过 Nginx 转发时才暴露出来。
另一个坑是内存溢出问题在 Docker 容器里的特殊表现。本地开发时 -Xmx1024m 没有问题,部署到容器后启动半天,访问几个接口就报 java.lang.OutOfMemoryError: insufficient memory。原因是 Docker 容器中 JVM 看到的是宿主机全部内存,而不是容器限制的内存,导致 JVM 分配堆内存时超过了容器 cgroup 的限额。解决方式是启动命令里把 -Xmx 调小,或者用 JDK 8u191 以上版本,让 JVM 自动感知容器内存限制。遇到过这个问题的同学,面试的时候能讲清楚这一层因果,其实是很加分的。
写在最后的一点体会
能坚持把一个项目从开发跑到部署上线,收获的不仅仅是代码能力,更重要的是建立起了一套完整的问题排查方法论。以后再遇到报错,你会习惯先判断是前端问题还是后端问题,是网络问题还是代码问题,而不是像刚开始那样对着异常日志发呆。
如果你已经做完苍穹外卖,想继续往深处扩展,我建议可以试试这几个方向:给订单模块加上退款流程,把超时未支付的处理改成延迟队列,给菜品模块增加秒杀或优惠券功能,或者把营业报表做成分钟级更新的实时看板。用这个项目当骨架做二次开发,学习效率比重新找一个项目要高很多。
