
简介这是一套面向JavaWeb初学者与进阶开发者的「苍穹外卖」在线订餐系统完整学习资料作者历时一个月整理包含项目源码与配套笔记适合希望通过真实项目巩固Servlet、JSP、MVC架构及框架集成等技能的读者。压缩包共约2000个文件整体13.61MB以388个xml配置、146个java源码、144个class字节码为主另含少量yml、json、xlsx及md文档覆盖后端逻辑、数据库映射与项目说明。资源围绕用户、菜品、订单等核心业务展开涉及数据库表结构设计、SQL增删改查、Spring与MyBatis集成、文件上传下载、登录鉴权与SQL注入防御等知识点并附有Markdown项目文档与图片资源便于对照源码理解各功能实现细节。目前已有16391人学习下载适合作为课程设计、毕业项目或JavaWeb实战练习的参考素材。1. 从一份外卖系统源码说起为什么它成了 JavaWeb 练手的热门选择有个做后端的朋友最近在群里晒了一张截图是他花一个月啃完的一个外卖点单系统从用户下单、购物车、地址管理到后台菜品维护、订单派送、数据统计整条链路都跑通了。他说自己之前学完 SSM 和 Spring Boot 之后一直卡在“会写 CRUD 但不知道怎么串成一个完整项目”的阶段直到把这个外卖系统从头到尾撸了一遍才算真正理解了企业级 JavaWeb 项目长什么样。这个项目就是很多人在找的“苍穹外卖”类实战系统关键词里提到的“苍穹外码 javaWeb”说的也是它。它本质上是一套前后端分离的外卖点单平台用户端负责浏览菜品、加购、下单、支付回调管理端负责分类、菜品、套餐、订单、员工、报表的全流程管理。适合谁适合已经学完 Java 基础、Spring Boot、MyBatis但缺一个完整项目把知识点串起来的人也适合想拿它当二次开发底座、改造成校园点餐或社区团购的开发者。2. 技术栈与目录结构一套标准的前后端分离骨架2.1 后端技术选型与分层设计这套系统的后端是典型的 Spring Boot MyBatis 组合配合 MySQL 做持久化Redis 做缓存和部分状态存储Maven 做依赖管理。分层上遵循 Controller → Service → Mapper 的三层结构实体类、DTO、VO 分开存放避免一个类从入参用到出参。常见做法是把sky-common和sky-pojo拆成独立模块sky-server作为启动模块这样公共工具、常量、异常处理不会和业务代码搅在一起。# 典型的多模块 Maven 结构 sky-take-out/ ├── sky-common/ # 公共模块常量、工具类、异常、上下文 ├── sky-pojo/ # 实体模块entity、dto、vo ├── sky-server/ # 业务模块controller、service、mapper │ └── src/main/resources/ │ ├── application.yml │ └── mapper/ # MyBatis XML 映射文件 └── pom.xml # 父工程统一管理版本sky-common里一般会放BaseContext基于 ThreadLocal 存当前登录用户 ID、JwtUtil生成和解析令牌、自定义异常和统一返回结果类。sky-pojo里 entity 对应数据库表DTO 对应前端传参VO 对应返回给前端的数据。这样拆的好处是改一个字段不会牵一发动全身坏处是类会变多新手容易在 DTO 和 VO 之间绕晕。我一般建议先看 entity 摸清表结构再看 controller 的入参出参最后回头看 service 怎么转换。2.2 前端结构与接口约定前端通常是 Vue 加 Element UI 或者原生 HTML 加 Axios 的组合管理端和用户端分开打包。管理端页面包括登录、员工管理、分类管理、菜品管理、套餐管理、订单管理、数据统计用户端包括首页菜品展示、购物车、下单、订单列表、地址管理。前后端通过 JSON 交互统一返回格式一般是{ code, msg, data }code 为 1 表示成功0 表示失败。// 前端请求封装示例Axios 拦截器 import axios from axios const request axios.create({ baseURL: /api, // 开发环境走代理生产环境走 Nginx timeout: 5000 }) // 请求拦截统一携带令牌 request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[token] token // 后端从请求头取 token 解析用户 } return config }) // 响应拦截统一处理失败提示 request.interceptors.response.use(response { const res response.data if (res.code 0) { return Promise.reject(new Error(res.msg || 请求失败)) } return res })这段代码的关键在于请求拦截器自动带令牌响应拦截器统一抛错业务页面就不用每个接口都写一遍判断。baseURL设为/api是为了开发时用代理解决跨域生产环境交给 Nginx 转发。令牌放在 header 里而不是 cookie是为了配合前后端分离的无状态鉴权。2.3 数据库表与核心字段数据库一般有员工表、分类表、菜品表、套餐表、套餐菜品关系表、订单表、订单明细表、地址表、用户表。订单表里会有订单号、用户 ID、地址、金额、支付状态、订单状态、下单时间、结账时间等字段。菜品表里会有分类 ID、名称、价格、图片、描述、状态。这里有个容易忽略的点订单状态和支付状态是两回事支付状态管钱订单状态管流程混在一起后面做退款和统计会很难受。表名核心字段说明employeeid, name, username, password, status管理端登录账号categoryid, name, type, sort分类type 区分菜品和套餐dishid, name, category_id, price, image, status菜品setmealid, name, category_id, price, status套餐ordersid, number, user_id, amount, pay_status, status订单主表order_detailid, order_id, dish_id, setmeal_id, number订单明细3. 核心链路拆解从登录鉴权到下单落库3.1 登录与 JWT 鉴权管理端登录走的是用户名密码校验密码在数据库里存的是 MD5 加密后的值登录成功后生成 JWT 令牌返回给前端后续请求通过拦截器校验令牌并解析出员工 ID 存入 ThreadLocal。// 登录服务核心逻辑 public Employee login(EmployeeLoginDTO dto) { // 1. 根据用户名查员工 Employee emp employeeMapper.getByUsername(dto.getUsername()); if (emp null) { throw new AccountNotFoundException(账号不存在); } // 2. 密码比对前端传明文数据库存 MD5 String inputPwd DigestUtils.md5DigestAsHex(dto.getPassword().getBytes()); if (!inputPwd.equals(emp.getPassword())) { throw new PasswordErrorException(密码错误); } // 3. 校验账号状态 if (emp.getStatus() StatusConstant.DISABLE) { throw new AccountLockedException(账号被锁定); } // 4. 生成 JWT载荷里放员工 ID MapString, Object claims new HashMap(); claims.put(empId, emp.getId()); String token JwtUtil.createJWT(jwtProperties.getSecretKey(), jwtProperties.getTtl(), claims); emp.setToken(token); return emp; }参数说明dto.getUsername()和dto.getPassword()来自前端表单jwtProperties从application.yml读取密钥和过期时间claims里只放必要信息不要放密码。逻辑上先查再比对再校验状态最后签发令牌。这里有个血泪经验MD5 比对时前端传的密码不要在后端再加密一次否则永远对不上因为前端已经加密过了——具体是前端加密还是后端加密要看项目约定常见做法是前端传明文、后端加密比对或者前端加密、后端直接比对。3.2 菜品与套餐的缓存设计菜品和套餐的查询频率高、修改频率低适合放 Redis 缓存。常见做法是分类下菜品列表用dish:categoryId作为 key套餐用setmeal:categoryId修改或删除时清除对应缓存。// 根据分类查菜品带缓存 public ListDishVO listWithCache(Long categoryId) { String key dish: categoryId; // 1. 先查 Redis ListDishVO cached (ListDishVO) redisTemplate.opsForValue().get(key); if (cached ! null !cached.isEmpty()) { return cached; } // 2. 缓存没有查数据库 ListDish dishes dishMapper.listByCategoryId(categoryId); ListDishVO result dishes.stream() .map(this::toVO) .collect(Collectors.toList()); // 3. 写回缓存设置过期时间防止雪崩 redisTemplate.opsForValue().set(key, result, 30, TimeUnit.MINUTES); return result; }参数说明categoryId是分类 IDredisTemplate是 Spring 提供的操作模板过期时间设 30 分钟是折中值。逻辑上先查缓存再查库再回写这是标准的 Cache Aside 模式。注意点修改菜品时要主动删除缓存而不是更新缓存因为更新缓存在并发下容易脏数据删除缓存虽然也有窗口期但配合过期时间基本够用。3.3 下单流程与事务控制下单是整个系统最复杂的链路涉及地址校验、购物车读取、金额计算、订单落库、明细落库、清空购物车必须放在同一个事务里。Transactional public OrderSubmitVO submitOrder(OrdersSubmitDTO dto) { // 1. 校验地址是否存在 AddressBook address addressBookMapper.getById(dto.getAddressBookId()); if (address null) { throw new AddressBookBusinessException(地址不存在); } // 2. 获取当前用户 Long userId BaseContext.getCurrentId(); // 3. 查询购物车 ListShoppingCart cartList shoppingCartMapper.list(userId); if (cartList null || cartList.isEmpty()) { throw new ShoppingCartBusinessException(购物车为空); } // 4. 构造订单主表 Orders orders new Orders(); orders.setNumber(String.valueOf(System.currentTimeMillis())); orders.setUserId(userId); orders.setAddress(address.getDetail()); orders.setAmount(calcAmount(cartList)); // 计算总金额 orders.setPayStatus(Orders.UN_PAID); orders.setStatus(Orders.PENDING_PAYMENT); orders.setOrderTime(LocalDateTime.now()); orderMapper.insert(orders); // 5. 构造订单明细 ListOrderDetail details cartList.stream().map(cart - { OrderDetail od new OrderDetail(); od.setOrderId(orders.getId()); od.setDishId(cart.getDishId()); od.setSetmealId(cart.getSetmealId()); od.setNumber(cart.getNumber()); return od; }).collect(Collectors.toList()); orderDetailMapper.insertBatch(details); // 6. 清空购物车 shoppingCartMapper.deleteByUserId(userId); return new OrderSubmitVO(orders.getId(), orders.getNumber(), orders.getAmount()); }参数说明dto.getAddressBookId()是前端选的地址 IDBaseContext.getCurrentId()从 ThreadLocal 取当前用户calcAmount遍历购物车累加金额。逻辑上先校验再构造再落库再清理六步都在一个事务里任何一步失败整体回滚。注意点订单号用时间戳在并发下可能重复常见做法是时间戳加用户 ID 后几位或者用雪花算法金额计算要用 BigDecimal 而不是 double否则会出现 0.1 0.2 不等于 0.3 的玄学问题。3.4 订单状态流转与定时任务订单状态从待付款到待接单到已接单到派送中到已完成中间还有取消和退款。超时未支付的订单需要自动取消常见做法是用 Spring Task 定时扫描。// 每分钟扫描一次超时未支付订单 Scheduled(cron 0 * * * * ?) public void cancelTimeoutOrder() { // 下单超过 15 分钟且未支付 LocalDateTime time LocalDateTime.now().minusMinutes(15); ListOrders list orderMapper.listByStatusAndTime(Orders.PENDING_PAYMENT, time); for (Orders order : list) { order.setStatus(Orders.CANCELLED); order.setCancelReason(超时未支付); order.setCancelTime(LocalDateTime.now()); orderMapper.update(order); } }参数说明cron表达式0 * * * * ?表示每分钟第 0 秒执行minusMinutes(15)是超时阈值。逻辑上查出符合条件的订单批量更新状态。注意点定时任务在集群环境下会重复执行常见做法是加分布式锁或者用 Redis 的 setnx 控制只有一个节点执行另外扫描时间间隔和超时阈值要匹配间隔太长会导致取消不及时。4. 避坑与常见问题排查4.1 跨域问题导致前端请求全部失败现象前端页面能打开但所有接口请求返回 CORS 错误浏览器控制台提示跨域被拦截。原因前后端分离部署时域名或端口不同浏览器同源策略拦截。解决开发环境在后端加跨域配置或者前端配代理生产环境用 Nginx 反向代理把前后端放同一个域名下。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns和allowedOrigins的区别前者支持通配符且允许携带凭证后者在携带凭证时不能用*。4.2 令牌过期后前端没有跳转登录现象令牌过期后接口返回 401但前端页面卡在原地不跳登录页。原因响应拦截器只处理了业务失败没处理 HTTP 状态码 401。解决在响应拦截器的错误分支里判断状态码401 就清本地令牌并跳登录。request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )4.3 图片上传后访问 404现象菜品图片上传成功但前端展示时 404。原因上传路径和静态资源映射路径不一致或者 Nginx 没配静态资源目录。解决确认application.yml里的上传路径和WebMvcConfig里addResourceHandlers的映射路径一致生产环境确认 Nginx 的location指向正确。4.4 事务不生效导致数据不一致现象下单时订单落库了但明细没落库或者购物车没清空。原因Transactional方法被同类内部调用代理不生效或者异常被 catch 了没抛出。解决确保事务方法通过代理调用异常要抛出RuntimeException子类或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4.5 Redis 缓存与数据库不一致现象后台改了菜品价格用户端还是旧价格。原因修改时只更新了数据库没删缓存或者删缓存失败没重试。解决修改和删除操作里同步删除对应缓存 key删除失败记录日志并重试查询时设置合理过期时间兜底。5. 二次开发改造思路从跑通到改成自己的项目把项目跑通只是第一步真正有价值的是改成自己的东西。我一般会从三个方向下手换业务场景、加功能模块、优化技术实现。换业务场景是最快的改造方式。外卖的点单逻辑和校园食堂点餐、社区团购、奶茶店小程序几乎一样改改分类名称、菜品字段、订单状态就能套用。比如把“派送中”改成“待取餐”把地址管理改成取餐点选择前端文案和图标换一换就是一个新项目。这里要注意数据库字段名不要大改改注释和前端展示就行否则 Mapper XML 和实体类都要跟着动容易翻车。加功能模块是练手的好办法。可以加优惠券模块用户领券、下单抵扣、过期失效可以加评价模块订单完成后打分写评论可以加库存模块菜品设置库存下单扣减取消回滚。加库存这个功能特别能练事务和并发控制因为多个用户同时下单同一个菜品时会出现超卖常见做法是用 Redis 预扣减或者数据库乐观锁。-- 乐观锁扣库存version 字段控制 UPDATE dish SET stock stock - #{num}, version version 1 WHERE id #{dishId} AND stock #{num} AND version #{version};这条 SQL 的关键在于stock #{num}防止扣成负数version #{version}防止并发覆盖。执行后判断影响行数为 0 说明被其他请求抢先了需要重试或提示用户。优化技术实现是进阶方向。可以把 JWT 换成 Spring Security 加 OAuth2把定时任务换成消息队列延迟队列把单机缓存换成 Redis 集群把日志换成 ELK 收集。每换一个都是在补一块企业级开发的拼图。验证改造是否成功我习惯用三步先跑通原有流程确保没改坏再针对新功能写接口测试最后用 JMeter 或 Postman 做并发测试看库存和订单是否一致。从那以后我每次改完核心链路都强制走一遍“下单-支付-取消-退款”全流程确认状态流转和金额计算都对得上。希望帮到你。本文还有配套的精品资源点击获取