简介:一套基于Java+SSM+MySQL+微信小程序开发的火锅店点餐系统毕业设计项目,面向需要完成毕设、课程设计或期末大作业的高校学生,以及希望快速搭建餐饮管理系统的开发者。系统集成了Spring、SpringMVC、MyBatis框架,采用微信小程序作为前端交互平台,覆盖顾客浏览菜品、选择锅底配菜、在线点餐,以及后台订单管理、菜品管理、用户管理等完整业务流程。资源包共1367个文件,大小约30.98MB,核心类型包括java后端源码、vue管理端页面、小程序wxml/wxss/js、MySQL数据库脚本、XML配置及说明文档等,目录结构规范,便于按模块学习与二次开发。项目经过严格调试,界面美观、操作简单,功能齐全,可直接作为高分毕设参考,也可用于课程设计与期末大作业。目前已有38人学习浏览,适合需要完整可运行项目以快速上手Java Web与小程序开发的学习者。
1. 火锅店点餐系统的技术栈,为什么偏偏是 SSM 加微信小程序
火锅店最忙的晚市,服务员一手菜单一手点菜宝在桌台间来回跑,顾客催加菜、后厨喊单、前台算错账,任何一环卡住,整店体验就崩了。用 Java+SSM+MySQL+微信小程序做的火锅店点餐系统,本质就是把这三件事搬进手机:顾客在小程序里选锅底、挑荤素、定辣度、提交订单,SSM 后端接收请求并完成下单事务,MySQL 落库菜品和订单数据。对做毕设的同学来说,这个组合技术栈不花哨但覆盖面广——Spring 的依赖注入和事务、SpringMVC 的接口映射、MyBatis 的动态 SQL、小程序的生命周期与本地缓存,全是面试常考点。下面按从零搭这类订单系统的顺序推进:数据模型与接口先行,SSM 后端跟上,小程序对接,最后落到部署验证和 MySQL 优化。
2. 先定 ER 图和接口再写代码:SSM 分层结构与火锅店点餐的 MySQL 建表
毕设项目最常见的烂尾原因不是代码写不完,而是表结构先烂了:订单状态不知道该放哪、菜品价格和订单价格混在一个字段、表之间没有外键也没有索引,后端越写越拧巴。所以我习惯先把 ER 图和接口定下来,再谈实现,这一步省下的返工时间远超画图那点功夫。
2.1 SSM 三个框架各管什么,小程序和服务端怎么对接
SSM 是 Spring、SpringMVC、MyBatis 的缩写,拆开看职责很清晰:Spring 管理对象和事务,SpringMVC 处理 HTTP 请求的映射和参数绑定,MyBatis 负责 SQL 与 Java 对象的转换。小程序和服务端的对接方式是 HTTP JSON 接口,小程序端wx.request发请求,后端@RestController返回统一结构的 JSON。
返回格式建议固定成{ code: 0, msg: 'ok', data: {} },code为 0 表示成功,非 0 是业务错误码。前端只判断code,不必为每个接口写一套 if/else。这个约定要在写第一个接口之前定下来,前后端各存一份,联调时才不会互相等。先把接口清单立住,后端和小程序照着实现:
| 功能 | 方法 | 路径 | 入参 | 返回 data |
|---|---|---|---|---|
| 微信登录 | POST | /api/user/login | { code } | userId |
| 菜品分类 | GET | /api/category/list | 无 | 分类数组 |
| 菜品列表 | GET | /api/dish/list | categoryId | 菜品数组 |
| 创建订单 | POST | /api/order/create | userId, tableId, items[] | orderId |
| 模拟支付 | POST | /api/order/pay | orderId | payTime |
| 订单列表 | GET | /api/order/list | userId, status | 订单数组 |
表里的接口基本就是小程序所有页面的数据来源。登录接口比较特殊,传的不是用户名密码,而是wx.login返回的临时 code;菜品列表是只读接口,查询时记得过滤status = 0的下架菜品;创建订单和支付两个接口一个写主表和明细,一个改状态,是后面事务和状态机的核心。
2.2 点餐系统的 ER 图与建表 SQL:用户、菜品、订单明细怎么落库
先画 ER 图再建表。用户和订单是一对多,一个订单对应多条订单明细;菜品分类对菜品是一对多;菜品和订单明细不建外键,明细里存的是下单时的快照。桌台不单独建主表,作为订单的属性table_id存放,扫码进来的桌号直接落这里。下面这套建表 SQL 是 MySQL 5.7 和 8.0 都能直接跑通的最小版本:
-- 用户表:小程序端通过 openid 识别身份,不存密码 create table t_user ( id int primary key auto_increment, openid varchar(64) not null unique, nickname varchar(50), avatar varchar(255), create_time datetime default current_timestamp ) engine = innodb default charset = utf8mb4 comment = '小程序用户'; -- 菜品分类:锅底、荤菜、素菜、小吃 create table t_category ( id int primary key auto_increment, name varchar(30) not null, sort int default 0 ) engine = innodb default charset = utf8mb4 comment = '菜品分类'; -- 菜品表:price 用 decimal,不用 float create table t_dish ( id int primary key auto_increment, category_id int not null, name varchar(50) not null, price decimal(10, 2) not null, stock int not null default 0, image varchar(255), status tinyint default 1 comment '1上架 0下架', create_time datetime default current_timestamp, key idx_category (category_id) ) engine = innodb default charset = utf8mb4 comment = '火锅菜品'; -- 订单主表:status 用整数状态机 create table t_order ( id bigint primary key auto_increment, order_no varchar(32) not null, user_id int not null, table_id varchar(20), total_amount decimal(10, 2) not null, status tinyint default 0 comment '0待支付 1已支付 2制作中 3已完成 4已取消', remark varchar(200) comment '辣度/忌口备注', create_time datetime default current_timestamp, pay_time datetime, unique key uk_order_no (order_no), key idx_user_time (user_id, create_time) ) engine = innodb default charset = utf8mb4 comment = '订单主表'; -- 订单明细:冗余菜名和价格,保留下单快照 create table t_order_item ( id bigint primary key auto_increment, order_id bigint not null, dish_id int not null, dish_name varchar(50), price decimal(10, 2) not null, count int not null, key idx_order (order_id) ) engine = innodb default charset = utf8mb4 comment = '订单明细';几个值得记住的设计:用户表不存密码,用openid唯一标识,这是小程序登录的通行做法;order_no有唯一键,顾客和客服查单都靠它,不用自增 id 对外暴露;订单明细冗余dish_name和price,菜品改名或调价后历史订单仍然能看到当时的菜名和价格,这叫快照冗余,点餐、外卖系统里是标配。
2.3 建表时的三个硬规矩:金额类型、索引顺序、快照冗余
- 金额一律
decimal(10,2)。float/double是二进制近似存储,0.1 + 0.2会得到0.30000000000000004,对账时能查出鬼来。Java 侧对应BigDecimal,不要用Double接收价格。 - 库存用整型,扣减靠条件更新。
update t_dish set stock = stock - #{count} where id = #{id} and stock >= #{count},影响行数为 0 就是库存不足,比先 select 再 update 少一次查询,还能避免并发超卖。 - 索引跟着高频查询走。顾客端最高频的查询是"按用户查订单,按时间倒序",所以建
(user_id, create_time)联合索引;order_no加唯一索引,按单号查订单可以走到const。这个差别在数据量上万之后会非常明显,第五章专门验证。
提示:建表阶段把这三条定死,后面 Controller、Service、Mapper 怎么写都不会出大格。最怕的是中途发现金额类型错了,改表结构连带改所有 SQL 和实体类。
3. SSM 后端实现:Controller-Service-Mapper 三层把登录、下单、扣库存串起来
表结构定好之后,后端就是顺着分层把接口一个个填上。SSM 项目最忌把业务逻辑堆在 Controller 里,我的习惯是 Controller 只做参数接收和结果包装,Service 处理业务,Mapper 只碰 SQL,谁越界谁难看。
3.1 Maven 工程结构与三个配置文件:先让 Spring 容器转起来
用 Maven 建工程,目录结构按包名com.hotpot组织:
hotpot-server ├── pom.xml └── src/main ├── java/com/hotpot │ ├── controller # 接口层,@RestController │ ├── service # 业务层,事务在这里 │ ├── dao # MyBatis Mapper 接口 │ ├── entity # 实体类 │ └── common # Result 包装、异常类 └── resources ├── spring-mybatis.xml ├── spring-mvc.xml ├── jdbc.properties └── mapper # OrderMapper.xml 等 SQL 文件Spring 容器启动顺序值得注意:web.xml里的ContextLoaderListener先加载spring-mybatis.xml创建根容器,DispatcherServlet再加载spring-mvc.xml创建子容器,子容器能看到父容器的 Bean,反之不行。所以 Service、Mapper 放进根容器,Controller 放进子容器。各文件职责如下:
| 配置文件 | 职责 | 关键配置 |
|---|---|---|
| web.xml | 启动入口 | ContextLoaderListener、DispatcherServlet |
| spring-mybatis.xml | 数据源、事务、会话工厂 | jdbc.properties、MapperScannerConfigurer |
| spring-mvc.xml | 接口层 | 注解驱动、Controller 包扫描 |
| jdbc.properties | 数据库连接 | url、username、password |
面试八股里常问的 SpringMVC 执行流程——请求先进DispatcherServlet,经HandlerMapping找到 Controller 方法,返回后由视图解析器处理——在这个项目里就是一次wx.request从进入到 JSON 返回的完整路径。你也可以借此把"Spring 容器和 SpringMVC 容器什么关系"讲清楚,这是比背流程更有区分度的答法。
3.2 微信登录接口:wx.login 的 code 换取 openid
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; @PostMapping("/login") public Result login(@RequestBody Map<String, String> params) { // 小程序端 wx.login 返回的临时 code,5 分钟有效 String code = params.get("code"); String openid = userService.code2Openid(code); Integer userId = userService.findOrCreate(openid); return Result.ok(userId); } }Service 里code2Openid做的事是拿 code 请求微信的jscode2session接口,把返回的 openid 在t_user里查一遍,没有就插入新用户并返回自增 id。同一微信号在同一个小程序里的 openid 是固定的,天然适合当用户主键。注意appid和secret只能放后端配置文件,小程序端发出去的任何请求别人都能看到,把 secret 写进小程序代码等于把钥匙挂在门口。
3.3 创建订单的 @Transactional 与乐观锁扣库存
下单接口是整套系统里最容易写错的地方。一次下单实际包含四步:生成订单主记录、遍历购物车明细、逐条扣库存、算总金额。四步要么全部成功要么全部回滚,所以 Service 方法上必须加事务:
@Service public class OrderServiceImpl implements OrderService { @Override @Transactional(rollbackFor = Exception.class) public Long createOrder(CreateOrderDTO dto) { String orderNo = generateOrderNo(); // 日期 + 随机数,保证唯一 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setTableId(dto.getTableId()); order.setStatus(0); // 待支付 order.setRemark(dto.getRemark()); // 辣度、忌口写这里 orderMapper.insert(order); BigDecimal total = BigDecimal.ZERO; for (ItemDTO item : dto.getItems()) { // 条件更新,影响行数为 0 说明库存不足 int rows = orderMapper.deductStock(item.getDishId(), item.getCount()); if (rows == 0) { throw new BizException("菜品库存不足:" + item.getDishName()); } Dish dish = dishMapper.selectById(item.getDishId()); OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(dish.getId()); oi.setDishName(dish.getName()); // 快照 oi.setPrice(dish.getPrice()); // 快照 oi.setCount(item.getCount()); orderItemMapper.insert(oi); total = total.add(dish.getPrice().multiply(BigDecimal.valueOf(item.getCount()))); } order.setTotalAmount(total); orderMapper.updateAmount(order.getId(), total); return order.getId(); } }对应 Mapper 里的扣库存 SQL 就是 2.3 说的条件更新:
<update id="deductStock"> update t_dish set stock = stock - #{count} where id = #{dishId} and stock >= #{count} </update>提示:
@Transactional默认只对RuntimeException回滚,自定义业务异常要么继承RuntimeException,要么在注解上写死rollbackFor = Exception.class。见过太多 SSM 项目事务"没生效",一查是异常被 try/catch 吞掉,或者异常类型不对,事务根本没机会回滚。
3.3.1 订单状态机:待支付、已支付、制作中、已完成
订单从创建到完成至少要经历四个状态:0 待支付、1 已支付、2 制作中、3 已完成,外加 4 取消。状态只允许向前流转,不允许跳转或回退。实现上不写自由 if,而是提供changeStatus(orderId, from, to)方法,update 语句带and status = #{from},影响行数为 0 就说明状态被并发改了,直接报错。这样小程序、后厨、前台三端对同一订单改状态也不会乱。
3.4 订单列表的 MyBatis 动态 SQL:一个 select 顶三份
订单查询的筛选条件在不同页面不一样:顾客端只看自己的,管理端可以按状态筛。用 MyBatis 的where加if标签把条件拼起来,参数没传的分支自动忽略:
<select id="listByUser" resultType="OrderVO"> select * from t_order <where> <if test="userId != null">and user_id = #{userId}</if> <if test="status != null">and status = #{status}</if> </where> order by create_time desc limit #{offset}, #{pageSize} </select>offset的计算是(pageNum - 1) * pageSize,这一步放 Service 里算,别让前端传。MyBatis 的 Mapper 接口没有实现类,Spring 启动时用 JDK 动态代理为每个接口生成代理对象,方法调用最终落到 XML 里的 SQL,这也是面试常考的"MyBatis 怎么和 Spring 整合"的答案核心。
4. 微信小程序端实现:菜品列表、购物车缓存到携带桌号提交订单
后端接口就绪后,小程序端要做的事很清晰:启动时登录拿 userId,点餐页拉分类和菜品,购物车用本地缓存管理,提交订单时带上桌号。用原生小程序实现完全够,不必引框架。
4.1 小程序目录与 app.json:把启动页配成点餐主页
miniprogram/ ├── app.js # 启动时 wx.login 拿 userId ├── app.json # 全局配置,pages 第一项是启动页 ├── app.wxss ├── utils/ │ ├── request.js # wx.request 封装 │ └── cart.js # 购物车本地缓存 └── pages/ ├── index/ # 点餐主页:分类 + 菜品列表 ├── cart/ # 购物车页 ├── order-confirm/ # 确认订单页:桌号 + 备注 ├── order-list/ # 我的订单列表 └── order-detail/ # 订单详情页面数据来源对应关系是:index 页调分类和菜品接口,cart 页只读本地缓存,order-confirm 页组合购物车缓存和桌号,order-list 和 order-detail 分别调订单列表和详情接口。app.json 里pages数组的第一项就是小程序启动后进入的页面,想改刚进入的加载页面,把目标页挪到数组第一个位置即可。点餐类小程序通常不需要 tabBar,一个主入口页面加几个跳转就能完成闭环。
4.2 封装 wx.request:统一 baseURL、loading 与错误提示
// utils/request.js const BASE_URL = 'http://localhost:8080/hotpot/api'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.showLoading({ title: '加载中' }); wx.request({ url: `${BASE_URL}${path}`, method, data, header: { 'Content-Type': 'application/json' }, success(res) { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail(err) { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); }, complete() { wx.hideLoading(); } }); }); } module.exports = { request, BASE_URL };这个封装的要点是 Promise 化和统一收口:页面里调用request('/dish/list', 'GET', { categoryId })拿到的直接是 data,不用每处重复处理 loading 和错误弹窗。开发阶段用localhost没问题,但手机真机预览时 localhost 指向手机自身,要改成电脑的局域网 IP;正式上线必须用已备案的 HTTPS 域名,并在小程序后台配置 request 合法域名,否则线上请求会被拦。这是小程序开发第一个容易卡住的地方。用 uniapp 开发的等价写法是uni.request,封装思路完全一样。
4.3 购物车本地缓存与辣度单选框:从加菜到算总价
购物车不需要服务端参与,本地缓存足够。用同步 API 最简单,因为购物车数据量小:
// utils/cart.js const KEY = 'hotpot_cart'; function getCart() { return wx.getStorageSync(KEY) || []; } function addDish(dish) { const cart = getCart(); const found = cart.find(item => item.id === dish.id); if (found) { found.count += 1; } else { cart.push({ id: dish.id, name: dish.name, price: dish.price, count: 1, spicy: '微辣' }); } wx.setStorageSync(KEY, cart); } function clearCart() { wx.removeStorageSync(KEY); } module.exports = { getCart, addDish, clearCart };火锅店的辣度选择适合用radio-group,选中的值随购物车项一起存进本地缓存,提交订单时放到 items 里,后端写进订单备注或明细字段:
<radio-group bindchange="onSpicyChange">Page({ onLoad(options) { // 扫码进入时参数在 scene 里,形如 tableId=12 const scene = decodeURIComponent(options.scene || ''); const tableId = scene.split('=')[1] || ''; this.setData({ tableId }); }, submitOrder() { const cart = getCart(); if (!cart.length) { wx.showToast({ title: '请先选菜', icon: 'none' }); return; } request('/order/create', 'POST', { userId: wx.getStorageSync('userId'), tableId: this.data.tableId, remark: this.data.remark, items: cart.map(item => ({ dishId: item.id, count: item.count, spicy: item.spicy })) }).then(orderId => { clearCart(); wx.redirectTo({ url: `/pages/order-detail/order-detail?orderId=${orderId}` }); }); } });小程序码的 scene 参数有长度限制,桌号这种短参数正好适用。如果入口是普通链接带 query,参数会直接出现在options里,比如options.tableId。桌号能进订单,后厨打单才知道送到哪桌,这是扫码点餐和普通外卖点餐的实质区别。
5. Tomcat 部署、MySQL 索引验证与两个能直接照抄的运维脚本
5.1 打包、部署、初始化:一条 curl 确认接口可用
环境上 JDK 8 加 Tomcat 8.5/9 加 MySQL 5.7/8.0 最省事。装完 JDK 先配置 JAVA_HOME 和 PATH,命令行java -version能正常输出再继续。项目根目录执行mvn clean package -DskipTests,target 下生成 war 包,拷到 Tomcat 的 webapps 目录,启动后访问http://localhost:8080/hotpot/看到页面说明容器起来了。
数据库初始化用命令行执行 SQL 文件:
mysql -uroot -p -e "create database hotpot_db default charset utf8mb4;" mysql -uroot -p hotpot_db < hotpot.sql然后验证登录接口:
curl -X POST http://localhost:8080/hotpot/api/user/login \ -H "Content-Type: application/json" \ -d '{"code":"dev-code"}'后端对dev-code这种本地调试值直接返回假 openid 或固定 userId,这样可以不依赖微信后台把整条链路跑通;真机联调时再换真实 code。这个"环境值短路"的技巧在前后端分离项目里很实用。
5.2 订单慢查询先别加缓存:用 EXPLAIN 确认联合索引生效
订单量起来后,最明显的慢查询是"我的订单"列表。先看这条 SQL 走没走索引:
explain select * from t_order where user_id = 4 order by create_time desc limit 20;看 explain 输出里的type和key两列:type是 ref、key是idx_user_time,说明联合索引生效;如果type是 ALL 或key是 null,就是全表扫描。联合索引的列顺序要和查询条件一致,user_id在前 explain 才能命中。按 order_no 查订单走唯一索引,type会是 const,这类查询适合客服按单号搜单。索引不是越多越好,点餐系统里最热的就是这两条查询,索引建到这里就够了。
5.3 两个可以直接照抄的 MySQL 脚本:自动备份 bat 与造数存储过程
Windows 上做每日自动备份,写一个批处理,配合任务计划程序每天凌晨执行:
@echo off set MYSQL_BIN=D:\mysql-8.0\bin set BAK_DIR=D:\hotpot_backup set DB_USER=root set DB_PASS=123456 set DB_NAME=hotpot_db set STAMP=%date:~0,4%%date:~5,2%%date:~8,2% "%MYSQL_BIN%\mysqldump" -u%DB_USER% -p%DB_PASS% --default-character-set=utf8mb4 %DB_NAME% > "%BAK_DIR%\%DB_NAME%_%STAMP%.sql"mysqldump是 MySQL 自带工具,备份文件名带日期不会互相覆盖;恢复时执行mysql -uroot -p hotpot_db < 备份文件.sql。造测试数据则用存储过程,一次生成过去 30 天里分布均匀的订单:
delimiter $$ create procedure gen_orders(in days int) begin declare i int default 0; while i < days * 200 do insert into t_order(order_no, user_id, table_id, total_amount, status, create_time) values (concat(date_format(now(), '%Y%m%d%H%i%s'), lpad(i, 6, '0')), 1 + floor(rand() * 20), concat('T', 1 + floor(rand() * 30)), round(100 + rand() * 300, 2), floor(rand() * 5), date_sub(now(), interval floor(rand() * days) day)); set i = i + 1; end while; end$$ delimiter ; call gen_orders(30);执行一次call gen_orders(30)生成 6000 条订单,答辩前用这批数据把分页、索引和状态筛选全部验证一遍,比临时手写 insert 可靠得多。delimiter的作用只是让客户端把整个过程体识别为一个语句,MySQL 5.7 和 8.0 写法一致。
5.4 URL Scheme:让顾客从公众号直接拉起带桌号的点餐页
复购场景里,顾客离店后再次点餐的入口往往在公众号菜单或短信里。可以在小程序后台生成 URL Scheme,形如weixin://dl/business?t=签名参数,用户点击后直接拉起小程序指定页面。生成 scheme 时把页面路径设为pages/index/index,参数里带上 tableId,这样顾客从公众号进来,落地页依然是带着桌号的点餐主页。注意 scheme 有有效期和生成数量限制,建议按桌台动态生成并缓存复用,不要每次点击都去调接口。把这条链路和桌号参数串起来,从店内扫码到店外复购的闭环就完整了——这也是答辩时比"能点餐"更能加分的完整度设计。
本文还有配套的精品资源,点击获取