Spring Boot外卖点餐系统实战:数据库设计、并发扣库存与支付回调
简介:Spring Boot 作为 Java 后端的主流开发框架,被广泛应用于各类企业级系统与课程设计中。一个完整的外卖点餐系统,不仅要实现基础增删改查,更要妥善处理数据库表结构设计、订单状态流转、高并发场景下的库存扣减、Redis 缓存优化以及支付回调的幂等性等核心问题。这些技术点共同构成了业务闭环,也是 Java 后端开发者必须掌握的关键能力。从普通用户浏览菜品、下单支付,到商家接单出餐,再到管理员后台管理,每个环节都考验着开发者对事务、锁、状态机等底层原理的理解。本文结合工程实践,系统梳理了基于 Spring Boot 构建外卖点餐系统的完整流程,涵盖技术选型、表结构设计、核心代码实现、部署运行及答辩准备,帮助读者将理论概念落地到真实项目中。 接手过不少用 Spring Boot 做的外卖点餐系统项目,有拿来交课程设计的,有想在小餐馆里跑通演示的,也有单纯为了面试时能讲清楚一个完整项目的。说句实在话,这个题目在网上非常常见,但大多数所谓“源码”都是把用户端、商家端、管理后台拼起来就完事,真问到底层表结构为什么要这样设计、下单时库存怎么扣、支付回调怎么保证幂等,很多人答不上来。
这篇文章想做的,就是把“Spring Boot 外卖点餐系统”从技术选型、数据库设计、核心代码实现,到部署运行和论文答辩准备,完整地梳理一遍。不管你是打算拿现成源码二次开发,还是想自己从零手写一个,里面涉及的业务闭环、状态机设计、并发扣库存、缓存使用,都是实际项目中能直接用上的东西。文章尽量少讲废话,多给能落地的方案。
1. 这套外卖点餐系统的定位:搞清楚要做什么,再谈技术选型
1.1 系统的业务闭环到底长什么样
先说清楚外卖点餐系统解决的是什么问题。它不是一个简单的“购物车+订单”代码,而是一套覆盖三类角色的完整业务流:普通用户负责浏览菜品、加购物车、下单、支付、查看订单状态;商家负责管理菜品上下架、接收新订单、接单、完成出餐;平台管理员负责审核商家、管理用户、处理异常订单、查看整体经营数据。
从用户下单到订单完成,一条完整的业务链路大概是:用户选好菜品放入购物车,提交订单后进入待支付状态;支付成功后订单推送给商家,商家接单开始制作;平台安排配送(演示类系统通常简化成商家确认发货或骑手取件);用户收到餐后确认完成,最后可以对订单进行评价。
这套业务闭环是这个题目最大的价值所在——它覆盖了典型的增删改查、状态流转、支付对接、并发控制、权限校验和缓存优化,几乎所有后端面试里常考的点都能在项目里找到落点。这也是为什么很多人拿它做毕业设计或者面试项目,因为它足够完整,又不像电商整站那么庞大。
1.2 Spring Boot 为主力,周边技术栈怎么搭配
技术选型上,这类系统最稳妥的组合是:
- 后端框架:Spring Boot 2.7.x,JDK 1.8 或 11。如果你用的是 Spring Boot 3.x,需要 JDK 17,并且要注意
javax.*包全部变成了jakarta.*,老教程里的代码直接复制大概率编译不过,第一次做这个项目的人容易卡在这里。 - 持久层:MyBatis-Plus。它比原生 MyBatis 少写大量 XML,单表 CRUD 直接继承
BaseMapper就能用,分页插件、条件构造器都能显著提升开发效率。如果你打算在论文里写“数据持久层设计”,MyBatis-Plus 的 LambdaQueryWrapper 也是很好讲的点。 - 数据库:MySQL 5.7 或 8.0,字符集统一 utf8mb4,排序规则用 utf8mb4_general_ci 或 utf8mb4_unicode_ci。
- 缓存:Redis,用来存验证码、热点菜品缓存、也可以承载购物车数据。
- 前端:Vue 2 或 Vue 3 + Element UI/Plus,配合 Axios 调用后端接口。如果要做小程序端,常见做法是 uni-app 复用 Vue 语法。
- 接口管理:Knife4j 或 springfox 集成 Swagger,方便前后端联调,也方便演示时快速查看接口。
很多人会问:这个规模要不要拆微服务?我的建议很明确:不要。外卖点餐系统作为单体 Spring Boot 应用完全够用,模块之间通过包结构划分边界,比如controller、service、mapper、entity分层,远比拆成订单服务、用户服务、商品服务要简单可靠。微服务带来的分布式事务、服务发现、配置中心,对一个演示级项目来说完全是负担,答辩时反而容易被追问到说不清。
2. 数据库设计:建表之前先想清楚订单和库存是怎么流转的
2.1 功能模块拆解与数据表规划
数据库是这类系统的地基。我一般会先画功能模块图,再倒推表结构。把系统拆成以下几个模块:用户模块、商家模块、菜品模块、购物车模块、订单模块、支付模块、评价模块和后台管理模块。
对应到数据库表,核心的表大致如下:
| 业务域 | 数据表 | 关键字段 |
|---|---|---|
| 用户 | user | id, username, password, phone, avatar, status |
| 地址 | address | id, user_id, consignee, phone, detail, is_default |
| 商家 | shop | id, name, status, notice, delivery_fee, start_time, end_time |
| 分类 | category | id, shop_id, name, sort |
| 菜品 | dish | id, shop_id, category_id, name, image, price, stock, status, description |
| 购物车 | cart | id, user_id, dish_id, quantity, checked |
| 订单 | orders | id, order_no, user_id, shop_id, total_amount, pay_amount, status, address_snapshot |
| 订单明细 | order_detail | id, order_id, dish_id, dish_name, dish_image, price, quantity |
| 评价 | comment | id, order_id, user_id, content, rating |
| 管理员 | admin | id, username, password, role |
| 优惠券(可选) | coupon | id, name, threshold, amount, stock |
2.2 订单表为什么要做“冗余快照”
在设计订单表和订单明细表时,最容易出现的低级错误是:把菜品名称、价格、图片直接关联查询 dish 表。看起来没问题,但实际一旦商家修改了菜品价格、改名或下架,历史订单显示就会错乱——用户明明下单时是 15 元,后来商家调成 20 元,用户查历史订单发现价格变成了 20 元,这就是不合格的订单设计。
正确做法是下单时把当前菜品信息“快照”到订单明细中,也就是order_detail表同时存dish_id、dish_name、dish_image、price。订单表本身也存address_snapshot(收货地址快照),不要只存地址表的 id,否则用户之后修改默认地址,历史订单的配送信息也会被改掉。这个设计无论是论文里的“数据库设计”章节,还是面试中“为什么订单表要冗余字段”,都是非常加分的点。
2.3 核心表结构与 DDL 示例
这里给出菜品表、订单表、订单明细表的 DDL 示例,照着建就能跑:
CREATE TABLE `dish` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `shop_id` bigint(20) NOT NULL COMMENT '所属商家ID', `category_id` bigint(20) NOT NULL COMMENT '分类ID', `name` varchar(64) NOT NULL COMMENT '菜品名称', `image` varchar(255) DEFAULT NULL COMMENT '菜品图片URL', `price` decimal(10,2) NOT NULL COMMENT '售价', `stock` int(11) NOT NULL DEFAULT 0 COMMENT '库存', `status` tinyint(4) NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `description` varchar(255) DEFAULT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_shop_category` (`shop_id`, `category_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '业务订单号', `user_id` bigint(20) NOT NULL, `shop_id` bigint(20) NOT NULL, `total_amount` decimal(10,2) NOT NULL COMMENT '商品总额', `pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额', `status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2商家已接单 3配送中 4已完成 5已取消', `address_snapshot` varchar(255) NOT NULL COMMENT '收货地址快照', `pay_time` datetime DEFAULT NULL, `finish_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status_time` (`user_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';CREATE TABLE `order_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL, `dish_id` bigint(20) NOT NULL, `dish_name` varchar(64) NOT NULL COMMENT '菜品名称快照', `dish_image` varchar(255) DEFAULT NULL COMMENT '菜品图片快照', `price` decimal(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';这里有几个细节值得注意:金额字段必须用decimal(10,2),不能用 float 或 double,否则金额累加会出现精度问题;订单表要单独建一个业务订单号order_no,并且加唯一索引,对外展示和支付回调都用它,内部自增主键只作为数据库主键使用;orders表建了(user_id, status, create_time)联合索引,这是按用户查订单列表最常见的查询条件,不建索引的话数据量上来之后 SQL 会很慢。
3. 核心业务链路的实现逻辑和方法
3.1 登录鉴权:JWT + 拦截器的常规组合
一般场景下,用户端和管理后台不需要做复杂 OAuth,JWT 是最常见的方案。流程是:用户提交用户名密码,后端校验通过后用io.jsonwebtoken.JJWT生成 token,返回给前端;前端把 token 存在 localStorage,并在 Axios 请求拦截器里通过Authorization: Bearer <token>传给后端;后端写一个拦截器,校验 token 是否存在且合法,然后放行。
拦截器配置时有一类路径必须放行:登录接口、注册接口、菜品列表接口、支付回调接口。支付回调尤其不能放在拦截器里要求登录,因为支付平台的服务器根本不会带你的 token,回调被拦截会导致支付成功但订单状态一直不更新,这是比较容易踩的坑。更安全的做法是把回调写在一个独立 Controller 中,路径单独匹配放行。
3.2 菜品数据用 Redis 做缓存:读多写少的典型场景
菜品列表和分类是典型读多写少的数据。用户打开首页要加载分类和菜品,商家改菜品的频率远低于用户浏览的频率。最朴素的缓存方案是:用 Redis String 缓存整个分类列表,key 设计成category:list;每个分类下的菜品列表按分类 id 缓存,key 设计成dish:category:{categoryId}。
读取逻辑是先查 Redis,命中直接返回;没命中则查数据库,再把结果写回 Redis,并设置过期时间(比如 30 分钟)。商家端新增或修改菜品后,主动删除对应的缓存 key,下次读取时重新加载。
缓存虽然简单,但有一个问题会被问到:缓存穿透、缓存击穿、缓存雪崩怎么办。实际项目中可以不把三个都做全,但至少要有基础防护——缓存空值能防穿透;热点 key 不加固定过期时间,改为逻辑过期能防击穿;不同分类设置不同的随机过期时间能降低雪崩概率。能讲清楚这几点,答辩时“Redis 在系统中的作用”这一问基本就稳了。
3.3 下单核心逻辑:事务、库存校验与订单状态机
下单接口是整个系统最不能出错的环节。正确流程是:前端把购物车勾选的菜品清单提交给后端,后端重新计算价格(绝不能信任前端传的金额),校验库存,生成订单号和订单明细,扣减库存,然后返回订单号给前端去发起支付。
一次后端重新计算价格的动作,天然避免了“用户篡改请求价格”的安全漏洞。这也是论文里“安全性设计”部分可以写的内容。
核心实现代码如下。先定义下单的 Service 方法:
@Override @Transactional(rollbackFor = Exception.class) public PayOrderVO submitOrder(SubmitOrderDTO dto) { // 1. 查询用户收货地址,生成地址快照 Address address = addressMapper.selectById(dto.getAddressId()); if (address == null) { throw new BusinessException("收货地址不存在"); } // 2. 遍历购物车,计算总金额并生成订单明细 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderDetail> detailList = new ArrayList<>(); for (CartItemDTO item : dto.getItemList()) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BusinessException("菜品已下架:" + item.getDishId()); } // 3. 核心:带库存条件的更新,返回影响行数 int rows = dishMapper.reduceStock(dish.getId(), item.getQuantity()); if (rows == 0) { throw new BusinessException("库存不足:" + dish.getName()); } totalAmount = totalAmount.add( dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())) ); OrderDetail detail = new OrderDetail(); detail.setDishId(dish.getId()); detail.setDishName(dish.getName()); detail.setDishImage(dish.getImage()); detail.setPrice(dish.getPrice()); detail.setQuantity(item.getQuantity()); detailList.add(detail); } // 4. 生成订单号和订单主表记录 Orders order = new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setShopId(dto.getShopId()); order.setTotalAmount(totalAmount); order.setPayAmount(totalAmount); order.setStatus(0); order.setAddressSnapshot(address.getFullAddress()); orderMapper.insert(order); // 5. 插入订单明细 for (OrderDetail detail : detailList) { detail.setOrderId(order.getId()); orderDetailMapper.insert(detail); } return new PayOrderVO(order.getId(), order.getOrderNo(), order.getPayAmount()); }对应的reduceStockSQL 在 DishMapper 中定义,重点看 where 条件:
<update id="reduceStock"> UPDATE dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity} </update>这里不能先SELECT stock判断,再执行UPDATE,因为并发同时下单时两个请求都查到库存充足,最后都会执行扣减,就超卖了。把判断条件放进 update 的 where 里,利用数据库行锁保证同一时刻只有一个事务能成功扣减,这是最简单也最有效的防超卖手段。
3.4 订单状态机与支付回调幂等
订单状态我一般设计成:待支付(0)→ 已支付/待接单(1)→ 商家已接单(2)→ 配送中(3)→ 已完成(4),另外还有已取消(5)。状态迁移必须是有向的,比如已完成的订单不允许再次取消,已取消的订单不允许变成已支付。代码上可以在 Service 层写一个状态变更方法,每次都校验当前状态和期望状态:
public void changeOrderStatus(Long orderId, Integer expectStatus, Integer targetStatus) { Orders order = orderMapper.selectById(orderId); if (order == null) { throw new BusinessException("订单不存在"); } if (!expectStatus.equals(order.getStatus())) { throw new BusinessException("订单状态已变更,请刷新后重试"); } order.setStatus(targetStatus); orderMapper.updateById(order); }支付回调是另一个重点。以微信支付/支付宝沙箱为例,支付平台会异步 POST 一个回调到你的接口,通知支付结果。回调接口要做三件事:验签、查订单、改状态。
验签确保请求确实来自支付平台;查订单是拿着回调里的order_no去数据库查一笔订单;改状态时必须判断当前订单是否已经是“已支付”,如果是就直接返回成功,不再重复更新。这就是幂等。如果不做幂等,支付平台重试回调时,可能导致同一笔订单被反复更新,商家端收到重复消息。
if (order.getStatus() != 0) { return "SUCCESS"; // 已经处理过了,直接返回成功 } order.setStatus(1); order.setPayTime(new Date()); orderMapper.updateById(order);对于超时未支付订单,常用方案是定时任务每分钟扫描一次超过 15 分钟未支付的订单,主动置为已取消并回补库存。单机部署用@Scheduled就够了,多实例部署时要注意加分布式锁,否则多个实例同时扫到同一笔订单,会重复回补库存。
4. 我踩过的坑:事务失效、并发超卖、跨域、时区与静态资源
4.1 下单事务不生效,查了一天才发现是同类调用
第一次实现下单时,我把submitOrder和内部的扣库存逻辑都写在同一个 Service 类里,然后在类内部直接调用“另一个私有方法”,方法上加了@Transactional,结果下单后数据库中订单和明细都没了,但库存却扣了。看起来是很诡异的问题,其实原因是在同一个类内部通过this调用带事务的方法,并不会经过 Spring 的代理对象,事务注解直接失效。
这个坑的解法很简单:把扣库存或其他需要事务保护的操作拆到另一个 Service 里,比如DishStockService,然后在原 Service 里注入它,通过代理对象调用。这也是为什么我上面示例代码里把dishMapper.reduceStock直接写在事务方法里,因为它和整体下单逻辑处于同一个事务边界内。
排查事务问题时,最快的方式是在方法入口打印日志,观察数据库里发生异常后数据是否回滚。也可以给application.yml加一行logging.level.org.springframework.transaction=DEBUG,看事务的提交和回滚日志。
4.2 并发超卖:先查库存再扣减就是错的
这个问题在上面下单代码里已经给出了正确做法,但值得单独提一次,因为我见过太多版本只在代码里写了“库存足够才扣”的判断,没有把条件放进 SQL 里。用两个线程同时请求数量为 1 的库存,两个请求都查到库存为 1,然后同时执行扣减,最后数据库库存可能变成 -1。用UPDATE ... WHERE stock >= #{quantity}之后,第二次 update 影响行数为 0,直接抛业务异常回滚,库存永远不可能为负数。
如果商家端使用 Redis 缓存菜品库存,还需要额外考虑缓存和数据库的一致性,这个在演示项目中可以不展开,但答辩被问到时至少要知道扣减的核心逻辑要放在数据库层面保证原子性。
4.3 跨域拦截器顺序问题:前端报跨域,接口却没进拦截器
前后端分离后,Vue 起的开发服务器在localhost:5173,后端在localhost:8080,浏览器会发跨域请求。常规方案是后端配置 CorsFilter 或者实现WebMvcConfigurer#addCorsMappings,但如果你同时注册了登录拦截器,很可能会发现前置 OPTIONS 预检请求直接被拦截器拦住了,根本走不到跨域处理。
解决办法有两个:一是在拦截器中直接放行 OPTIONS 请求;二是跨域配置改用CorsFilter,它会先于拦截器处理请求,添加跨域响应头后再进入业务逻辑。我倾向于第二种,配置简单,也更稳妥。
@Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); }4.4 本地好好的,服务器部署就出问题:时区和静态资源
这类问题在新手项目里出镜率极高。本地开发时数据库连接串里写了serverTimezone=Asia/Shanghai,开发机系统时区也是东八区,一切正常;部署到 Linux 服务器后,容器默认时区是 UTC,订单创建时间就比实际时间慢 8 小时。
排查方式:先date看服务器时间;再查 MySQL 的time_zone;最后看应用日志里的时间和数据库里的时间是否一致。修正方案是数据库连接串显式指定时区,同时启动容器时把时区映射进去,比如 Docker Compose 里加上TZ=Asia/Shanghai环境变量。
静态资源的问题则是上传的菜品图片保存在本地磁盘/data/images/,但页面访问http://ip:8080/images/xxx.jpg返回 404。原因是没有把本地目录映射成静态资源路径。需要继承WebMvcConfigurer添加资源映射:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceHandler("file:" + uploadDir); }如果是演示项目用相对路径存图片,部署之后路径错乱的概率很大。建议统一用一个绝对路径配置项,比如app.upload-dir=/data/images,启动前手动创建目录。
5. 从源码到能跑:本地启动、Docker 部署和演示环境准备
5.1 拿到源码包后怎么在本地快速跑起来
不管源码是网上拿的还是自己写的,第一次运行都要按顺序做这几件事:先看 README,再初始化数据库,接着改配置,然后启动后端,最后启动前端。
初始化数据库时,必须确认数据库脚本的字符集。如果你用的是 MySQL 8.0,而脚本是从 5.7 导出的,执行时大概率遇到Unknown collation: 'utf8mb4_0900_ai_ci'之类的报错,解决方式是全局替换排序规则为utf8mb4_general_ci。
后端配置一般只改application.yml里的数据源和 Redis 地址:
spring: datasource: url: jdbc:mysql://localhost:3306/food_delivery?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你自己的密码 redis: host: localhost port: 6379启动命令用 Maven 的话是mvn spring-boot:run,或者打 jar 包再跑。启动日志里看到Started Application in x.xx seconds说明后端起来了,然后用 Swagger 或直接浏览器访问登录接口验证连通性。
前端部分如果是 Vue 项目,通常改的是.env.development里VUE_APP_BASE_URL=http://localhost:8080,然后执行npm install再npm run dev。Vue 项目 node_modules 安装很慢是常见情况,建议切换 npm 镜像源。
5.2 Docker 部署:数据库、Redis 和后端一键编排
如果要把项目部署到服务器演示,Docker Compose 是最省心的方式。一个标准的docker-compose.yml包含三块:MySQL、Redis、后端应用。后端打包成镜像前,需要把application.yml里的数据库地址从localhost改成 MySQL 容器服务名,比如mysql,因为容器之间是通过服务名解析的。
一个可用的 Dockerfile 示例:
FROM openjdk:8-jdk-alpine VOLUME /tmp COPY target/food-delivery.jar app.jar ENV TZ=Asia/Shanghai ENTRYPOINT ["java","-jar","/app.jar"]再配合 docker-compose:
version: "3" services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: food_delivery ports: - "3306:3306" volumes: - ./sql:/docker-entrypoint-initdb.d - mysql-data:/var/lib/mysql redis: image: redis:7 ports: - "6379:6379" app: build: . depends_on: - mysql - redis ports: - "8080:8080" volumes: - /data/images:/data/images volumes: mysql-data:这样部署完后,前端静态资源可以用 Nginx 托管,/api路径反向代理到http://localhost:8080。前端打包产物是dist目录,直接放到 Nginx 的 html 目录下即可。
5.3 演示环境准备:不要让演示变成事故现场
很多人在答辩或给客户演示时栽在环境准备上。提前一天检查这几项:数据库是否导入了测试数据,至少保证有 5 个以上分类、30 个以上菜品,菜品图片能够正常访问;商家账号的状态是否是“营业中”,否则用户端看不到这家店;Redis 是否能连接,缓存 key 是否过期导致第一次访问特别慢;支付环节如果没有真实商户号,一定提前在代码里预留“模拟支付”入口,点击按钮直接改订单状态为已支付,避免演示时卡在扫码支付环节。
演示时最尴尬的情况不是功能少,而是接口加载超时、图片 404、下单后订单列表刷不出来。花十分钟把这些静态数据和本地环境调好,演示流畅度会高很多。
6. 论文与答辩准备:这套系统怎么“讲”出价值
6.1 论文结构的整体安排
毕业设计论文的结构虽然各校有差异,但大体是固定的。放在外卖点餐系统上,比较顺的章节安排是:
- 绪论:背景与意义、国内外研究现状、论文主要工作。这一章不用写太多,重点是引出“外卖点餐系统为什么值得研究”。
- 相关技术介绍:Spring Boot、MyBatis-Plus、Redis、Vue、JWT 等。写技术简介时不要只抄概念,要写“本项目用它解决什么问题”,比如 Redis 用来做缓存和验证码存储。
- 需求分析:功能性需求(用户、商家、管理员各角色用例图)、非功能性需求(性能、安全、易用性)。
- 系统设计:总体架构图、功能模块图、数据库 E-R 图、数据库表结构设计。
- 系统详细设计与实现:这一章是核心,按业务模块逐个介绍实现思路,配合核心类图、时序图、关键代码片段。下单模块、支付回调、库存扣减是最值得重点写的。
- 系统测试:功能测试用例表、测试结果、性能测试简单分析。
- 总结与展望:总结做的工作,指出不足和后续扩展方向。
写论文时最容易犯的错是一大段一大段贴代码。论文不是源码分析文档,代码只要截取关键片段,比如下单事务方法、库存扣减 SQL,每段代码后面跟一段文字解释设计思路,这比堆代码有价值得多。
6.2 答辩 PPT 与演示顺序
答辩 PPT 不要超过 15 页,时间控制在 8 到 12 分钟。推荐顺序是:封面;选题背景与意义;技术路线与开发环境;需求分析(痛点);系统总体设计(架构图 + 流程图);核心模块设计与实现(用户下单、商家接单、支付回调);界面演示截图;系统测试;总结与展望。
演示环节比 PPT 更重要。建议先演示用户端,从注册/登录、浏览菜品、加入购物车、下单、支付,到订单状态变成已支付;再切换商家账号,演示接单、完成出餐的操作;最后切换到管理后台,展示订单报表数据。整套流程控制在 5 分钟左右,节奏要稳,每切一个页面说一两个关键点就行,不用把所有功能都点一遍。如果现场网速不稳,提前录一份演示视频作为备选方案,关键时刻能救场。
6.3 高频答辩问题与答题思路
以下问题在我见过的答辩中被问次数最多,每个都值得提前准备:
- 为什么选 Spring Boot 而不是 SSH/SSM?回答核心是自动配置、内置容器、生态丰富,开发效率高。
- MyBatis-Plus 相比 MyBatis 解决了什么问题?单表 CRUD 无需写 SQL、条件构造器、分页插件。
- 订单状态是怎么流转的?把状态机图表讲清楚,重点强调状态变更必须校验前置状态。
- 怎么防止商品超卖?讲清楚
UPDATE ... WHERE stock >= quantity的原子性,再提到事务回滚。 - Redis 在项目中用在哪些地方?验证码、菜品缓存、购物车。问得深一点会问缓存和数据库一致性,可以回答“更新数据库后主动删除缓存”的策略。
- 支付回调怎么保证安全?验签、回调接口放行、幂等处理。
- 下单后一直不支付怎么办?定时任务扫描超时订单,取消订单并回补库存,单机用
@Scheduled,多实例考虑分布式锁。
一个人做系统时,代码可以自己研究着写,但到了答辩环节,必须知道自己写的每个模块解决的核心问题是什么。源码是别人整理的也好、自己手写的也好,业务逻辑、表结构、状态流转这些内容一定要自己拆一遍,因为老师不会按着源码问,他只会按着“你做的系统”来问。
最后再分享一点体会:如果时间允许,把这个项目拆开重写一遍,尤其是下单、库存、回调这三个核心点,比单纯把源码跑起来有用得多。很多人在答辩时被问倒,恰恰是因为只记住了项目“能跑”,却说不清楚为什么这样设计。把表结构为什么冗余、库存为什么用条件更新、回调为什么要求幂等这些底层逻辑想明白,这套外卖点餐系统才真正算你的东西。
本文还有配套的精品资源,点击获取
