
简介面向Java课程设计和毕业设计的饥了么外卖系统APP项目是一套可运行的移动端外卖应用完整示例。项目按用户、商家、配送员多角色划分源码以Java/Kotlin客户端与后端逻辑为主实现对下单、接单、配送等核心流程的模拟并涉及Spring Boot、MySQL、RESTful API、Material Design界面规范及JWT授权等知识点适合正在学习Android开发与服务端整合的学生研读。压缩包共270个文件、大小约150MB主要由52个Kotlin源码、23个Java源码、42个XML布局与配置、35个so原生库、5个APK安装包以及SQL脚本、docx课程设计报告和png界面预览图组成从源码、安装包到课程报告覆盖完整。已有168人浏览学习。除可直接安装体验的APK外附带的外卖报告与数据库脚本还能帮助理解表结构、接口设计和界面实现便于在课程设计或毕业设计中快速开展二次开发与文档撰写。1. 夏天点外卖和写外卖系统是一回事这份 Java 项目在练什么下午两点是外卖订单的高峰错一道菜用户就退款漏一次并发库存商家就骂人——写外卖系统练手练的就是把这种强时序、高并发的业务用 Java 稳稳托住。这份名为“饥了么外卖系统 APP”的资源是一套完整可运行的前后端分离外卖项目后端 Spring Boot MyBatis数据库 MySQL缓存 RedisApp 端按 uni-app 结构组织覆盖用户从注册登录、浏览菜品、加购物车、下单支付到商家接单、骑手配送的完整闭环。它适合正在积累 Java 项目经验的在校学生、准备 Java 面试的求职者以及想快速搭一套订单类系统底子的初级开发。接下来这篇笔记我从架构、数据表、核心代码到部署坑位逐层拆解尽量让你拿到手后能顺利跑起来而不是卡在某个隐性问题上一整天。2. 整体架构与技术选型Spring Boot MyBatis Redis 为什么够用2.1 前后端分离的部署形态这套“饥了么外卖系统 APP”不是传统的 JSP 单体应用而是前后端分离。后端只提供 RESTful 接口跑在 8080 端口前端项目独立启动开发模式下走代理访问后端App 端则直接请求后端接口地址。前后端分离带来三个直接好处第一App 端和 Web 管理端可以共用同一套后端接口不用为每个端各写一套逻辑第二后端接口的职责边界清晰登录鉴权、订单流转、商户管理各自收敛到对应的 Controller 里第三部署时可以分开处理后端放服务器前端可以走 Nginx 托管App 端打包后直接用。你在资源里会看到后端是一个完整的 Maven 工程前端目录和 App 端目录是独立文件夹启动方式各自独立。2.2 核心技术栈与选型理由这套系统的技术选型比较“务实”没有引入微服务、消息队列这类重组件但对理解 Java 后端主流程已经足够。层次技术选型选择理由后端框架Spring Boot 2.x自动配置、内嵌 Tomcat省去繁琐的 XML 配置ORM 层MyBatisSQL 由自己掌控订单这类复杂查询更好调优数据库MySQL 5.7 / 8.0订单数据强一致MySQL 事务支持可靠缓存Redis购物车高频读写token 也需要过期机制鉴权JWTApp 端无状态登录不用依赖 Session 粘滞前端Vue / uni-app管理后台用 VueApp 端用 uni-app 一套代码多端编译构建工具Maven依赖管理成熟团队协作成本低这套组合放在中小型订单类项目中是“标准答案”。Spring Boot 负责把业务接口串起来MyBatis 负责把 SQL 查清楚Redis 扛住购物车这种读多写少的场景。面试官问“为什么不用微服务”你完全可以说单体架构把业务聚焦在一个进程内下单事务更好保证等用户量起来再按订单、用户、商户去拆分而不是一开始就引入分布式复杂度。2.3 项目目录结构与代码分层打开资源里的后端工程你会看到典型的 Controller-Service-Dao 分层结构。我一般会先看包结构来快速判断项目的组织是否规范这套系统的目录结构大概是这样的src/main/java/com/example/order/ ├── config/ // 配置类RedisConfig、CorsConfig、WebMvcConfig ├── controller/ // 接口层UserController、OrderController、CartController ├── service/ // 业务层OrderServiceImpl、CartServiceImpl ├── dao/ // 持久层接口OrderMapper、DishMapper ├── entity/ // 数据库实体User、Orders、Dish、OrderDetail ├── dto/ // 传输对象LoginDTO、OrderSubmitDTO ├── common/ // 通用返回体、异常处理 └── util/ // JwtUtil、OrderNoGeneratorController 只做参数接收和结果包装不写业务 SQLService 层处理事务和状态流转Dao 层对应 MyBatis 的 Mapper 接口。这样的分层好处是出问题时你能顺着接口调用链快速定位不需要把整个文件翻完。如果你是第一次接手别人项目先按这个顺序读代码启动类 → 配置文件 → Controller → Service 实现类 → Mapper XML比你从头到尾硬读效率高得多。3. 数据库设计与订单状态机外卖系统最核心的几张表3.1 ER 关系与关键表清单外卖系统本质上是一个“用户 — 商家 — 菜品 — 订单”的多方关系模型。这套资源里的表设计我梳理了一下核心表大致是七张表名对应实体核心职责user用户用户账号、手机号、默认地址merchant商家店铺名称、起送价、配送费dish菜品菜品名称、价格、所属商家cart购物车用户临时选择的菜品与数量orders订单主表订单号、总金额、状态、下单时间order_detail订单明细每个订单里的菜品快照delivery配送信息配送地址、骑手、送达状态订单主表和订单明细表拆开是这套设计里最关键的一步。为什么不能把菜品直接塞在订单表里因为同一个订单可能包含多个菜品如果把菜品用逗号拼在订单表字段里后续统计“哪个菜品卖得好”时SQL 写起来会非常痛苦。拆成明细表之后一个订单对应多条明细每一条明细保存菜品的名称、价格、数量即使商家后续改了菜品价格历史订单里也保留了下单那一刻的快照。3.2 订单表与订单明细表字段拆解订单主表的字段设计基本决定了业务边界。这里我按常见做法整理一下关键字段CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单号业务编号, user_id BIGINT NOT NULL COMMENT 下单用户ID, merchant_id BIGINT NOT NULL COMMENT 商家ID, total_amount DECIMAL(10,2) NOT NULL COMMENT 订单总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1待接单 2配送中 3已完成 4已取消, address VARCHAR(255) NOT NULL COMMENT 配送地址, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, KEY idx_user_id (user_id), KEY idx_merchant_id (merchant_id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单号一定要用业务编号而不是自增主键否则前端展示订单号时容易暴露当日订单量而且多表联调时自增 ID 不够直观。这里用order_no做唯一索引订单号生成规则一般是“日期随机数商户后缀”保证并发下不重复。订单明细表的字段要注意一个点明细里要冗余存储菜品名称和价格而不是只存 dish_id。因为菜品价格会变如果只存 dish_id订单创建之后再去查菜品表拿到的可能是改价后的数据金额就对不上了。快照思想是整个订单表设计的核心这也是为什么 order_detail 要有 dish_name 和 price 两个冗余字段。3.3 订单状态流转与并发控制这套系统的订单状态是在后端代码里通过整型常量约束流转的状态机路径大概是待支付(0) → 待接单(1) → 配送中(2) → 已完成(3) ↓ 已取消(4)用户提交订单后在待支付状态支付成功后进入待接单商家接单后状态变为配送中骑手或商家确认送达后变成已完成。取消操作只允许在待支付和待接单两个状态发生如果已经在配送中就只能走售后流程而不是直接取消。并发控制是外卖系统最容易翻车的地方核心冲突点在于“库存扣减”。常见做法是乐观锁更新UPDATE dish SET stock stock - 1 WHERE id #{dishId} AND stock 1;这条 SQL 的巧妙之处在于利用stock 1条件让并发请求只有一个能更新成功返回受影响行数为 0 时说明库存不足。不需要手动加锁InnoDB 行锁会在更新时自动生效。我在实际项目里见过有人先 SELECT 再 UPDATE两步操作之间库存容易超卖后来全部改成这条原子更新语句问题就没了。4. 核心模块实现拆解登录、购物车与下单流程的代码级解读4.1 JWT 登录鉴权登录模块用的是 JWT 而不是 Session这是移动端项目的常见选择。JWT 的优点是后端不需要维护会话状态token 本身携带用户标识和过期时间App 端只负责存 token 并在后续请求头带上它。public class JwtUtil { private static final String SECRET your-secret-key; private static final long EXPIRE 7 * 24 * 60 * 60 * 1000L; // 7天 public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }claim(userId, userId)是把用户 ID 塞进 token 的声明里后续业务接口从 token 里解析 userId就省掉了每次查库确认身份的往返。EXPIRE设成 7 天用户一次登录一周内有效这个时间可以根据业务调整金融类项目建议缩短到 2 小时外卖类项目 7 天体验更好。有了 token 之后还要注册一个拦截器来校验所有需要登录的接口。常见的做法是继承HandlerInterceptorAdapter或实现HandlerInterceptor在preHandle里解析请求头public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token.substring(7)); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }这里有一个容易忽略的点前端传 token 时通常带Bearer前缀别直接用整段 token 去解析先substring(7)去掉前缀。解析失败统一返回 401前端拿到 401 就知道要跳登录页了。我在给这个系统加接口时就在拦截器里把request.setAttribute(userId, ...)这步加上后续 Controller 里直接拿 userId不用重复解析 token。4.2 购物车用 Redis 缓存购物车是这个项目里最能用 Redis 说事的地方。用户的购物车数据不需要强持久化服务端 Redis 存储天然适合。常见做法是用 Hash 结构存储key 是用户 IDfield 是菜品 IDvalue 是加购数量。Service public class CartServiceImpl implements CartService { Autowired private StringRedisTemplate redisTemplate; private static final String CART_PREFIX cart:; public void addItem(Long userId, Long dishId, Integer count) { String key CART_PREFIX userId; redisTemplate.opsForHash().increment(key, String.valueOf(dishId), count); } public MapString, Object getCart(Long userId) { String key CART_PREFIX userId; MapObject, Object entries redisTemplate.opsForHash().entries(key); // 遍历 entries组装 dishId 列表去查菜品详情 return result; } public void clearCart(Long userId) { String key CART_PREFIX userId; redisTemplate.delete(key); } }这里用StringRedisTemplate而不是RedisTemplate是因为RedisTemplate默认用 JDK 序列化往 Redis 里存的数据会带一串\xAC\xED二进制头肉眼排查数据时完全没法看。用StringRedisTemplate存字符串和哈希值是纯文本redis-cli里直接能查到加购记录调试成本低很多。Hash 结构在这里比 String 结构更合适的原因一个用户对应多个菜品Hash 天然表达“用户 → {菜品: 数量}”的映射同时increment方法可以在并发加购时原子增加数量不会出现重复请求把数量覆盖的问题。购物车数量上限一般限制在 99前端加购时先读一次当前数量做判断这个属于业务规则实现时在addItem方法里校验即可。4.3 下单事务实现下单是外卖系统事务性最强的操作涉及生成订单、扣减库存、清空购物车三个步骤任何一个失败都要整体回滚。Override Transactional(rollbackFor Exception.class) public OrderResult submitOrder(OrderSubmitDTO dto) { Long userId dto.getUserId(); Long merchantId dto.getMerchantId(); // 1. 生成订单号 String orderNo OrderNoGenerator.generate(); // 2. 查询购物车组装明细 MapObject, Object cartItems cartService.getCart(userId); // 3. 创建订单主表记录 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setMerchantId(merchantId); order.setStatus(0); orderMapper.insert(order); // 4. 批量插入订单明细并扣减库存 for (Map.EntryObject, Object entry : cartItems.entrySet()) { Long dishId Long.valueOf(entry.getKey().toString()); Integer count Integer.valueOf(entry.getValue().toString()); OrderDetail detail new OrderDetail(); detail.setOrderId(order.getId()); detail.setDishId(dishId); detail.setCount(count); orderDetailMapper.insert(detail); int result dishMapper.decreaseStock(dishId, count); if (result 0) { throw new RuntimeException(库存不足); } } // 5. 清空购物车 cartService.clearCart(userId); return new OrderResult(orderNo, order.getTotalAmount()); }Transactional(rollbackFor Exception.class)是关键默认Transactional只在抛出 RuntimeException 时才回滚如果抛出的是受检异常事务不会回滚数据就会处于半提交状态。加上rollbackFor Exception.class之后任何异常都触发回滚。库存扣减的result 0判定要和第 3 章的乐观锁 SQL 配合使用。每次扣减前先查一遍库存是不可靠的因为查和扣之间存在时间差。这里直接写decreaseStock的 SQL 包含stock count条件返回 0 说明库存不足抛出异常让整个事务回滚之前插入的订单主表和明细都会被撤销数据一致性有保障。4.4 商家接单与状态更新商家端和用户端的订单状态流转在这个系统里是共享同一张 orders 表的只是操作入口不同。商家接单的接口逻辑比较简单校验当前订单状态是“待接单”然后更新为“配送中”。PutMapping(/merchant/accept) public Result acceptOrder(RequestParam Long orderId) { int rows orderMapper.updateStatus(orderId, 1, 2); if (rows 0) { return Result.error(订单状态已变动请刷新后重试); } return Result.success(); }updateStatus(orderId, 1, 2)对应一条带状态条件的 UPDATE SQLUPDATE orders SET status 2 WHERE id #{orderId} AND status 1。为什么要带原本的状态条件因为用户可能同时点击了取消订单两个请求并发过来一个改成取消一个改成配送中如果更新时不带状态条件最后的写入会覆盖前一个操作业务就乱套了。带状态条件更新只有一行数据能更新成功另一方拿到rows 0后友好提示这是典型的乐观锁实现方式。双端状态同步的手段因人而异这个系统里如果采用轮询前端每几秒拉一次订单状态如果上 WebSocket商家端能实时收到新订单提醒。轮询实现简单但实时性差WebSocket 效果好但要额外维护连接。作为练手项目先用轮询把流程跑通之后再换 WebSocket 也不迟。5. 避坑笔记部署和调试中最容易翻车的七个问题5.1 数据库与 Redis 的三个坑坑一后台查到的订单时间比实际时间慢 8 小时。现象订单创建时间、用户注册时间显示成前一天下午而不是当前时间页面和数据库里的时间都不对。原因MySQL 驱动连接串里没有设置serverTimezone驱动默认用了服务器本地时区而多数 VPS 或本地 MySQL 安装的时区是 UTC和北京时间差 8 小时。解决修改项目配置文件中的数据库连接 URL加上时区参数spring: datasource: url: jdbc:mysql://localhost:3306/order_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai改完一定要重启应用并且注意如果之前已经写入过错误时间的数据重启后新数据才会正常旧数据需要脚本订正。从那以后我每次建项目第一件事就是先把serverTimezone写进连接串不然上线第一天就要被运营催着改数据。坑二用 RedisTemplate 存储购物车后键名带了一长串奇怪的二进制前缀。现象redis-cli里查看 key发现不是cart:1而是\xAC\xED\x00\x05t\x00\x05cart。原因默认的RedisTemplate使用 JdkSerializationRedisSerializer序列化后额外带了类型信息。这个数据 Java 端自己读没问题但跨端查看、清理数据时非常不方便。解决配置一个使用 StringRedisSerializer 的 RedisTemplate或者直接改用 StringRedisTemplate购物车这种纯字符串数据结构用字符串序列化完全够用。坑三清空购物车时把整个 key 删掉导致后续加购数据丢失。现象用户清空购物车之后再添加商品购物车显示为空必须重新登录才恢复。原因clearCart用了redisTemplate.delete(key)这个操作是删掉整个 Hash。如果你在购物车 Hash 里还存了用户所选商家的 ID删除后这部分信息也没了。解决清空购物车时只删除菜品相关的 field保留必要的上下文信息或者彻底简化购物车 Hash 只存菜品和数量商家信息在所有菜品归属同一个商家这个前提下可以不存。校验用户所有加购菜品属于同一家店再按用户维度删除即可。5.2 前后端联调与外网访问的坑坑四前端页面能打开但登录后所有请求都报 404 或 CORS 错误。现象浏览器控制台报CORS policy: No Access-Control-Allow-Origin header或前端请求能到达但拿不到响应。原因前端开发服务器和后端接口不在同一个端口浏览器跨域拦截。解决后端加一个全局跨域配置类而不是在每个 Controller 上加CrossOrigin注解注解方式维护成本高Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意.allowedOriginPatterns(*)是 Spring Boot 2.4 之后的写法老版本用.allowedOrigins(*)两者在允许凭证时行为不同。如果是新版 Spring Boot 用了allowedOrigins带allowCredentials(true)时会报错这个坑很隐蔽。坑五App 端真机调试时接口请求一直失败后端日志看不到任何访问记录。现象手机和电脑连同一个 Wi-FiApp 访问localhost:8080请求超时。原因手机上的localhost指向手机自己而不是你的电脑。必须在 App 的接口配置里把 IP 改成电脑的局域网 IP。解决先在电脑终端执行ipconfigWindows或ifconfigMac查局域网 IP然后把前端工程里的 baseURL 改成http://192.168.x.x:8080。还要留意防火墙是否放行 8080 端口否则局域网设备也访问不了。坑六后端代码看着没问题但启动时报Invalid bound statement (not found)。现象启动时 Spring Boot 正常拉起调用 Mapper 方法时报 MyBatis 找不到 SQL 语句。原因MyBatis 的 Mapper 接口和 XML 文件没有正确配对。常见情况是 XML 文件放在了src/main/java目录下Maven 默认不把.xml文件打进类路径。解决把 Mapper XML 放到src/main/resources/mapper目录并在application.yml里配置mybatis: mapper-locations: classpath:mapper/*.xml或者另一种做法是直接在pom.xml里配置resources把 XML 文件一并打入构建结果。两种方式任选其一我更推荐第一种目录结构更标准。5.3 Maven 与端口相关的两个坑坑七第一次导入工程时 Maven 下载依赖极慢甚至卡住不动。现象IDEA 下方 Maven 面板一直转圈提示下载某个 jar 包失败。原因默认 Maven 中央仓库在国外网络访问不稳定。解决在~/.m2/settings.xml里配置阿里云镜像mirror idaliyunmaven/id urlhttps://maven.aliyun.com/repository/public/url mirrorOfcentral/mirrorOf /mirror配置完成后删掉本地仓库里之前下载失败残留的.lastUpdated文件重新刷新 Maven 工程。注意 IDEA 里如果设置了离线模式要先切回在线模式。坑八后端启动失败提示Port 8080 was already in use。现象控制台报端口被占用Spring Boot 起不来。原因本机有其他进程占用了 8080 端口。解决换端口是最省事的在application.yml里改成 8081但要注意前端代理或 App 端 baseURL 也要对应修改。如果一定要保留 8080查到占用进程后结束它netstat -ano | findstr 8080 # Windows 输出 PID 之后 taskkill /PID 进程号 /FMac 系统用lsof -i :8080和kill -9 PID同样处理。我一般会直接把端口改成 9090避免和本机其他项目冲突。6. 部署到自己的机器上配置修改、数据库迁移与 App 联调技巧6.1 配置文件里必须改的四个位置拿到项目先别急着运行第一件事是打开src/main/resources/application.yml重点检查数据库连接、Redis 地址、服务端口和文件上传路径。数据库连接四件套url/username/password要换成你自己 MySQL 的信息Redis 如果没设密码就保持默认服务端口我习惯改成 9090 避开系统占用文件上传路径如果项目里涉及菜品图片上传要把绝对路径指到你本地有写权限的目录不然上传功能一调就报FileNotFoundException。6.2 从 MySQL 5.7 切到 MySQL 8.0 的注意点这套系统和很多在校项目一样默认用 MySQL 5.7 写的。如果你本地装的是 8.0直接沿用旧驱动会踩一个明确的坑MySQL 8.0 移除了旧的驱动类com.mysql.jdbc.Driver必须改成com.mysql.cj.jdbc.Driver。同时在连接串里必须写serverTimezone不然 8.0 会直接拒绝连接。切高版本之前还要确认一遍密码加密方式8.0 默认caching_sha2_password部分旧连接池版本需要额外配allowPublicKeyRetrievaltrue看你用的连接池情况决定是否追加。6.3 App 端联调时最容易忽略的编码问题前端和 App 端调试时请求参数如果有中文菜名一定要确认工程的编码统一为 UTF-8出现中文乱码时先检查后端接口返回的Content-Type是否为application/json;charsetUTF-8再检查前端请求头是否带了Content-Type: application/json; charsetutf-8。还有一点容易被忽略App 端打包成 APK 后接口地址不能是localhost必须打包前改成一个真实可访问的服务器 IP 或域名否则你本机测试好好的装到别人手机上一请求就全线超时。我帮人排查过这个问题找了半天最后发现是包没重新打旧的localhost地址还留在里面。这套系统跑起来之后你还可以拿它当面试项目的底子追问自己几个问题订单状态并发更新怎么保证安全、购物车 Redis 结构为什么用 Hash、扣库存的 SQL 为什么不会超卖。把这些问题想透效果比刷十道面试题更实在。从那以后我每次部署这种带 Redis 和 MySQL 的订单项目都强制先检查时区、再确认序列化方式、最后打个包真机走一遍流程三次排查下来翻车概率基本归零希望帮到你。本文还有配套的精品资源点击获取