简介:微信点餐小程序毕业设计完整资料,基于微信小程序与SSM框架、MySQL数据库实现,面向计算机相关专业毕业生及Java Web学习者。资源覆盖用户登录注册、喜欢列表、热菜推荐和订单生成等核心功能模块,从需求分析、总体设计到系统测试均有文档支撑,可帮助快速完成毕业设计或课程项目。压缩包共1262个文件,约60.63MB,包括java后端源码、vue前端页面、小程序wxml/wxss、SQL数据库脚本、项目配置文件、毕业论文doc与答辩pptx,以及视频演示mp4等,结构完整便于对照学习。已有133人学习下载,适合需要完整项目方案、开题与答辩材料,或想参考实际小程序点餐业务逻辑的开发者。
1. 微信点餐小程序毕设:为什么这套技术栈值得选,以及你将要面对的真实工作量
微信点餐小程序作为毕业设计选题,这几年一直是 Java 方向学生的热门选择。它的核心价值在于一条完整链路:用户在微信小程序里浏览菜品、加入购物车、提交订单,后端用 SSM 框架处理业务逻辑,MySQL 负责数据持久化。技术栈不算新,但覆盖面正好卡在课程重点和面试常问之间,而且天然自带移动端交互场景,演示效果比纯网页直观得多。这套方案适合两类人:一是想稳扎稳打完成毕设、把基础框架完整走通的学生,二是想拿它做课程项目或外包练手、需要一套可复用底座的开发者。接下来的内容按数据库设计、后端接口、小程序对接、联调避坑、答辩收尾五个环节展开,全程围绕"能跑通、能讲清、能答辩"这三个目标。
2. 先把数据库立住:点餐业务的 7 张表设计与订单状态机
2.1 为什么要先设计表结构,而不是先写代码
做毕设最容易翻车的方式,就是打开 IDE 直接写 Controller,写到一半发现购物车数据没地方放、订单明细拆不出来,又回头改表。数据库是整套系统的地基,表结构一旦定下来,后端 Service 怎么写、小程序页面怎么渲染,都跟着它走。对于点餐这类业务,核心诉求非常明确:用户能浏览菜品、把菜品加入购物车、提交订单、查看订单状态。围绕这四个动作,数据模型可以拆成"用户—菜品"两个主维度,再用购物车和订单把行为串起来。
这套设计在答辩时也很有优势。评委大概率会问"为什么这么设计表",如果你的回答是"用户表存登录态、菜品表存商品信息、订单表和明细表是一对多关系",就已经把数据库设计的核心逻辑讲清楚了。比一上来谈索引优化、分库分表要务实得多,也更符合毕设的评分预期。
2.2 7 张核心表的建表语句与字段设计
我一般会建 7 张表:用户表、菜品分类表、菜品表、购物车表、订单表、订单明细表、轮播图表。下面直接给可执行的建表 SQL,字段注释已经写在里面了,复制到 Navicat 或命令行执行即可。
-- 用户表:一个微信用户对应一条记录,openid 是唯一标识 CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信 openid,用户唯一标识', `nickname` VARCHAR(64) DEFAULT NULL COMMENT '用户昵称', `avatar_url` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL', `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号,预留字段', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';-- 菜品分类表:如主食、小吃、饮品 CREATE TABLE `category` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(32) NOT NULL COMMENT '分类名称', `sort` INT DEFAULT 0 COMMENT '排序权重,数字越小越靠前', `status` TINYINT DEFAULT 1 COMMENT '1 启用,0 停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品分类表';-- 菜品表:价格用 DECIMAL 而不是 FLOAT,避免金额精度问题 CREATE TABLE `dish` ( `id` INT NOT NULL AUTO_INCREMENT, `category_id` INT NOT NULL COMMENT '所属分类 ID', `name` VARCHAR(64) NOT NULL COMMENT '菜品名称', `price` DECIMAL(10,2) NOT NULL COMMENT '单价,精确到分', `image` VARCHAR(255) DEFAULT NULL COMMENT '菜品图片 URL', `description` VARCHAR(255) DEFAULT NULL COMMENT '菜品描述', `status` TINYINT DEFAULT 1 COMMENT '1 在售,0 下架', PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜品表';-- 购物车表:user_id + dish_id 唯一,避免同一用户重复添加同一菜品 CREATE TABLE `cart` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL COMMENT '用户 ID', `dish_id` INT NOT NULL COMMENT '菜品 ID', `quantity` INT DEFAULT 1 COMMENT '数量', `checked` TINYINT DEFAULT 1 COMMENT '是否勾选,1 勾选 0 未勾选', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_dish` (`user_id`, `dish_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='购物车表';-- 订单表:一个订单对应一个用户,状态用 TINYINT 存储 CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单编号,前端展示用', `user_id` INT NOT NULL COMMENT '下单用户 ID', `total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额', `status` TINYINT DEFAULT 0 COMMENT '0 待付款 1 待制作 2 待取餐 3 已完成 4 已取消', `address` VARCHAR(255) DEFAULT NULL COMMENT '收货地址,自取可留空', `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';-- 订单明细表:记录每个订单包含哪些菜品,与订单表是一对多关系 CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT, `order_id` INT NOT NULL COMMENT '订单 ID', `dish_id` INT NOT NULL COMMENT '菜品 ID', `dish_name` VARCHAR(64) NOT NULL COMMENT '菜品名称,下单时快照', `price` DECIMAL(10,2) NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL COMMENT '购买数量', PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';-- 轮播图表:首页顶部广告位,毕设加分项 CREATE TABLE `banner` ( `id` INT NOT NULL AUTO_INCREMENT, `image` VARCHAR(255) NOT NULL COMMENT '轮播图 URL', `sort` INT DEFAULT 0 COMMENT '排序', `status` TINYINT DEFAULT 1 COMMENT '1 启用,0 停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='轮播图表';几点设计说明:user表的openid必须加唯一索引,因为微信登录后拿到 openid 就要按它查用户;菜品价格用DECIMAL(10,2)而不是FLOAT,金额用浮点存储会出现 0.1 + 0.2 不等于 0.3 的精度问题;order_item里的dish_name和price是快照字段,等用户下单之后再改菜品价格,历史订单依然能显示正确的订单快照。cart表加uk_user_dish唯一索引,是为了防止同一个用户把同一道菜加出多条购物车记录。
2.3 订单状态机:用一张表说清状态流转
订单状态是答辩时最容易深度追问的点,建议一开始就按状态机设计。我常用 5 个状态,用数字表示比字符串更省空间,业务判断时也更快。
| 状态值 | 含义 | 可流转到的状态 |
|---|---|---|
| 0 | 待付款 | 1、4 |
| 1 | 待制作 | 2、4 |
| 2 | 待取餐 | 3 |
| 3 | 已完成 | 无,终态 |
| 4 | 已取消 | 无,终态 |
这个流转规则要先写死在脑子里,再写进代码。最简单的做法是在 Service 层定义一个状态校验方法,每次更新前查一次当前状态,不允许跳变。比如用户点击"取消订单",只在状态为 0(待付款)时才放行,已经进入 1(待制作)的订单必须走"商家取消"逻辑。这块在答辩时能体现你对业务完整性的考虑,很加分。
提示:订单状态不要用字符串直接存在数据库里。'PAYED'、'FINISHED' 这类写法查询慢、容易拼错,TINYINT 加注释才是稳妥做法。
3. 用 SSM 把后端接口写出来:工程结构、核心 Controller 与 MyBatis 映射
3.1 Maven 工程结构与关键依赖
SSM 指的是 Spring + SpringMVC + MyBatis 的组合。Spring 管对象,SpringMVC 管接口路由,MyBatis 管数据库操作,三者各司其职。工程结构我习惯按 controller、service、mapper、entity、common 五个包组织,清晰也符合课程设计的规范。
先看一下 pom.xml 里最关键的几个依赖,版本号按你自己本地的 Spring 环境对齐即可:
<!-- SpringMVC:处理接口请求 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.x</version> </dependency> <!-- MyBatis 与 Spring 整合 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.x</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.x</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.x</version> </dependency> <!-- 数据库连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.x</version> </dependency> <!-- JSON 序列化 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.x</version> </dependency>如果你的工程是用 IDEA 建的 Spring Initializr 项目,只需要把 mybatis、mybatis-spring、mysql-connector-java、druid 手动加进去,其余 Spring 系列依赖会由父工程统一管理。注意 mybatis-spring 的版本要和 Spring 版本兼容,2.0.x 对应 Spring 5,别混用。
3.2 Spring 与 MyBatis 的整合配置
SSM 整合的配置文件比 Spring Boot 繁琐一些,核心是 spring-mybatis.xml。这个文件把数据源、SqlSessionFactory、Mapper 扫描三件事一次性搞定,写完基本不用再碰。
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <!-- 读取数据库配置 --> <context:property-placeholder location="classpath:jdbc.properties"/> <!-- Druid 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <!-- SqlSessionFactory:指定数据源和 mapper.xml 位置 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.demo.canteen.entity"/> </bean> <!-- 扫描 Mapper 接口 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.demo.canteen.mapper"/> </bean> </beans>jdbc.properties 文件放在 src/main/resources 下:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/canteen?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=你的数据库密码serverTimezone=Asia/Shanghai必须加,不加会报时区错误,这是新手最常见的报错之一。useSSL=false是本地开发时减少警告用的。数据库名 canteen 换成你自己创建的库名即可。
3.3 登录接口与下单接口:从 Controller 到 MyBatis 映射
我先写最核心的登录接口。微信小程序端调用wx.login拿到一个临时 code,后端拿这个 code 去微信接口换 openid。这个调用只能在后端做,因为需要 AppSecret,不能暴露在小程序代码里。
@RestController @RequestMapping("/api/user") public class UserController { @Autowired private UserService userService; /** * 微信登录:小程序传 code,后端换 openid,查不到就自动注册 */ @PostMapping("/login") public Result login(@RequestBody Map<String, String> params) { String code = params.get("code"); if (StringUtils.isBlank(code)) { return Result.error("code不能为空"); } // 调用微信 code2Session 接口,传入 appid、secret、code,返回 openid String openid = userService.getOpenid(code); // 按 openid 查用户,查不到说明是新用户,自动注册 User user = userService.findByOpenid(openid); if (user == null) { user = userService.register(openid); } return Result.ok(user); } }这段代码要注意两个职责区分:getOpenid负责网络请求,findByOpenid和register负责数据库操作,Service 层把两块逻辑串起来。Result是我定义的工具类,统一包装返回体为 code、msg、data 三个字段,小程序端解析时只用判断 code 是否为 0。
再看下单接口,这是业务逻辑最密集的一个接口,涉及金额计算、订单生成、明细写入、购物车清空四步。我用@Transactional保证多表操作要么全成功要么全回滚。
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; /** * 提交订单:从购物车勾选的菜品生成订单 */ @PostMapping("/create") public Result create(@RequestBody OrderVO orderVO) { // 1. 根据用户 ID 查询购物车中勾选的菜品列表 // 2. 遍历菜品,计算总金额 // 3. 生成唯一订单号,保存订单主表 // 4. 批量保存订单明细 // 5. 删除已下单的购物车记录 Long orderId = orderService.createOrder(orderVO.getUserId(), orderVO.getAddress(), orderVO.getRemark()); return Result.ok(orderId); } }@Service public class OrderServiceImpl implements OrderService { @Autowired private CartMapper cartMapper; @Autowired private OrderMapper orderMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, String address, String remark) { // 查询购物车勾选的菜品 List<CartVO> cartList = cartMapper.selectCheckedByUserId(userId); if (cartList == null || cartList.isEmpty()) { throw new BusinessException("购物车为空"); } // 计算总金额 BigDecimal total = BigDecimal.ZERO; for (CartVO cart : cartList) { total = total.add(cart.getPrice().multiply(BigDecimal.valueOf(cart.getQuantity()))); } // 生成订单号:时间戳 + 用户ID + 随机数 String orderNo = System.currentTimeMillis() + "" + userId + RandomUtil.randomNumbers(4); // 插入订单主表 Orders order = new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(total); order.setStatus((byte) 0); order.setAddress(address); order.setRemark(remark); orderMapper.insert(order); // 插入订单明细 for (CartVO cart : cartList) { OrderItem item = new OrderItem(); item.setOrderId(order.getId()); item.setDishId(cart.getDishId()); item.setDishName(cart.getDishName()); item.setPrice(cart.getPrice()); item.setQuantity(cart.getQuantity()); orderItemMapper.insert(item); } // 清空购物车 cartMapper.deleteCheckedByUserId(userId); return order.getId(); } }金额计算用BigDecimal而不是double。double计算金额会出现 0.1 + 0.2 = 0.30000000000000004 这类精度问题,下单金额和数据库对不上,非常难受。BigDecimal.valueOf(quantity)把 int 转成 BigDecimal 再参与乘法,保证金额精确到分。
MyBatis 的 XML 映射文件写在 resources/mapper 目录下,比如 CartMapper.xml 里的查询勾选菜品:
<select id="selectCheckedByUserId" resultType="com.demo.canteen.vo.CartVO"> SELECT c.id AS id, c.dish_id AS dishId, c.quantity AS quantity, d.name AS dishName, d.price AS price, d.image AS image FROM cart c LEFT JOIN dish d ON c.dish_id = d.id WHERE c.user_id = #{userId} AND c.checked = 1 </select>这里用LEFT JOIN关联菜品表,查出菜品名称和价格快照。resultType 直接映射到 CartVO,因为 CartVO 里有 dishId、dishName、price、quantity 这些字段,和查询结果别名一一对应,就不需要写 resultMap 了。如果你发现某个字段查出来是 null,先检查 SQL 别名和 VO 字段名是否一致,这是 MyBatis 映射最常见的玄学问题,其实根本不是玄学,就是名字对不上。
4. 小程序端对接:登录态、购物车与下单流程的代码级实现
4.1 wx.request 的统一封装:避免每个页面重复写请求逻辑
小程序端没有 axios,所有网络请求都走wx.request。如果每个页面都写一遍完整配置,代码会非常冗余,而且出错时排查困难。我习惯在一开始就封装一个request.js,统一管理 BaseURL、超时时间、错误提示。
// utils/request.js const BASE_URL = 'http://localhost:8080' // 后端接口地址,上线时改成 HTTPS 域名 function request(url, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json' }, timeout: 10000, success: (res) => { // 后端统一返回 { code:0, msg:'ok', data:... } if (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) } }) }) } module.exports = request封装之后,每个页面的请求代码就从十几行缩减到两三行,而且错误提示统一。这里有个关键点:后端返回的 code 必须约定好,0 表示成功,非 0 表示业务失败。不要在 success 里只判断 HTTP 状态码,因为 HTTP 200 不代表业务成功,业务异常同样会返回 HTTP 200 但 code 非 0。这个坑如果不提前约定,小程序端会反复出现"明明有数据但页面不渲染"的情况。
4.2 登录态处理:从 wx.login 到 openid 的完整流程
微信小程序的登录态逻辑是固定的:app.js 的 onLaunch 里调用wx.login拿 code,传给后端换 openid,再把用户信息存到 storage。不传 code 直接让用户填手机号登录的方案绕开了微信体系,反而会在答辩时被问住。标准流程如下:
// app.js const request = require('./utils/request') App({ onLaunch: function () { // 先检查本地是否已有用户信息 const userInfo = wx.getStorageSync('userInfo') if (userInfo && userInfo.id) { this.globalData.userInfo = userInfo return } // 调用 wx.login 获取临时 code wx.login({ success: (res) => { const code = res.code // 传给后端,后端用 code 换 openid 并返回用户数据 request('/api/user/login', 'POST', { code: code }) .then((data) => { wx.setStorageSync('userInfo', data) this.globalData.userInfo = data }) } }) }, globalData: { userInfo: null } })wx.login得到的 code 有效期很短,只能使用一次,所以每次冷启动都要重新调。后端拿到 code 后换 openid,再查数据库。用户的 id 就是后续所有业务接口的入参,购物车、下单都要用到。不要尝试用昵称、手机号做用户标识,小程序端拿到的昵称可以随时改,openid 才是微信体系里用户维一的身份标识,这个观念会在答辩时被重点确认。
4.3 购物车与提交订单:页面逻辑的数据流
购物车页面是点餐小程序交互最核心的部分。用户从首页菜品列表点"加入购物车",到购物车页修改数量、勾选、提交订单,整个数据流是这样的:首页把 dishId 传给购物车接口,后端在购物车表插入或更新数量;购物车页加载时调查询接口,展示菜品名、单价、数量、勾选态;提交时把所有勾选的菜品的 quantity 汇总传给订单接口。
// pages/cart/cart.js const request = require('../../utils/request') Page({ data: { cartList: [], totalAmount: '0.00', selectedCount: 0 }, onShow: function () { this.loadCart() }, // 加载购物车列表 loadCart: function () { const userInfo = wx.getStorageSync('userInfo') if (!userInfo || !userInfo.id) { return } request(`/api/cart/list?userId=${userInfo.id}`, 'GET') .then((data) => { const cartList = data.map(item => { // 后端返回的 price 是字符串,转成浮点数用于前端计算展示 return { ...item, price: parseFloat(item.price).toFixed(2) } }) this.setData({ cartList: cartList }) this.calcTotal() }) }, // 勾选/取消勾选 toggleCheck: function (e) { const id = e.currentTarget.dataset.id const cartList = this.data.cartList const index = cartList.findIndex(item => item.id === id) cartList[index].checked = !cartList[index].checked this.setData({ cartList: cartList }) this.calcTotal() }, // 计算总金额 calcTotal: function () { let total = 0 let count = 0 this.data.cartList.forEach(item => { if (item.checked) { total += item.price * item.quantity count += item.quantity } }) this.setData({ totalAmount: total.toFixed(2), selectedCount: count }) }, // 提交订单 submitOrder: function () { const userInfo = wx.getStorageSync('userInfo') const checkedItems = this.data.cartList.filter(item => item.checked) if (checkedItems.length === 0) { wx.showToast({ title: '请先勾选菜品', icon: 'none' }) return } request('/api/order/create', 'POST', { userId: userInfo.id, address: '食堂自取', remark: '' }).then((orderId) => { wx.showToast({ title: '下单成功', icon: 'success' }) // 下单成功后跳转订单详情页 wx.redirectTo({ url: `/pages/order/detail?id=${orderId}` }) }) } })购物车页面的parseFloat(item.price).toFixed(2)是必要操作,因为后端返回的 DECIMAL 字段在 JSON 序列化后可能是字符串。前端参与计算时必须显式转成数字,否则 12.00 + 8.50 会变成字符串拼接的 "12.008.50"。这个坑几乎每个人都踩过,列在避坑章里重点讲。
提示:小程序端不要直接操作数据库,所有数据变更必须走后端接口。在控制台里手动改 storage 里的数据只是改本地缓存,刷新后会被后端数据覆盖。
5. 联调与部署避坑:5 个真实翻车点与排查路径
5.1 小程序请求一直 fail:域名校验和开发者工具设置
现象:小程序页面打开,所有请求都进入 fail 回调,控制台报 "request:fail"。
原因:微信开发者工具默认开启"合法域名校验",小程序要求所有请求域名必须在微信公众平台配置为 HTTPS 白名单。本地开发时后端跑在 http://localhost:8080,根本不在白名单里,请求直接被拦截。
解决:开发阶段在微信开发者工具的"详情 → 本地设置"里勾选"不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书",这个问题就消失了。上线前必须把后端部署到 HTTPS 域名并在公众平台配置 request 合法域名,否则真机体验版也会请求失败。
5.2 后端启动报时区错误:MySQL 连接串少了一个参数
现象:Tomcat 启动时控制台报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,或者数据库查询正常但保存时间比本地早 8 小时。
原因:MySQL 8.x 的驱动对时区敏感,连接串里没有指定 serverTimezone 时会读取系统时区,报错或默认使用 UTC。
解决:把 jdbc.url 改成jdbc:mysql://localhost:3306/canteen?serverTimezone=Asia/Shanghai,问题立即解决。如果已经出现时间字段误差,改完连接串重启服务即可。顺带建议所有时间字段都用 DATETIME 类型,统一存"北京时间",不要混用 TIMESTAMP,减少后续时间处理的混乱。
5.3 购物车重复添加同一个菜品:唯一索引加 UPSERT
现象:用户把同一道"红烧肉"加了三次,购物车列表出现三行一样的菜,各自数量为 1。用户还得手动删,体验很差。
原因:插入购物车时用INSERT INTO cart (user_id, dish_id, quantity) VALUES (?, ?, 1),每次加购都插入新记录,没有处理已存在的场景。
解决:利用uk_user_dish唯一索引,把插入语句改成INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + 1。这样第一次加购插入新记录,第二次加购命中唯一索引后直接把数量加 1,一行 SQL 解决重复问题。对应的 MyBatis mapper 里这样写:
<insert id="addToCart"> INSERT INTO cart (user_id, dish_id, quantity, checked) VALUES (#{userId}, #{dishId}, 1, 1) ON DUPLICATE KEY UPDATE quantity = quantity + 1 </insert>5.4 金额显示错乱:DECIMAL 与前端浮点运算的精度
现象:下单总金额显示 24.300000000000004,后端入账金额 24.30,前后端对不上。
原因:后端用 BigDecimal 计算没问题,但前端用0.1 * 3这类浮点运算时,JavaScript 的 IEEE 754 浮点表示会产生精度误差。
解决:前端每次收到金额字段就parseFloat(item.price).toFixed(2),参与计算时统一先转数字再算,最后用 toFixed(2) 格式化展示。前端参与金额计算只有这几种场景,加购计算、购物车勾选计算、订单详情展示,单独封装一个formatPrice函数复用,不要每个页面各写一套。
5.5 订单状态被跳过:后端不校验状态流转
现象:用户提交订单后状态是 0(待付款),跳过付款直接给后端发请求把状态改成 3(已完成),订单显示完成,但商家根本没收到新订单。
原因:更新订单状态的后端接口没有做前置校验,只要传订单 ID 和新状态就执行 UPDATE。
解决:Service 层加状态机校验,只有当前状态符合流转规则才允许更新:
/** * 校验订单状态流转是否合法 */ public void checkStatusTransition(byte currentStatus, byte targetStatus) { if (currentStatus == 0 && (targetStatus == 1 || targetStatus == 4)) return; if (currentStatus == 1 && (targetStatus == 2 || targetStatus == 4)) return; if (currentStatus == 2 && targetStatus == 3) return; throw new BusinessException("非法的订单状态流转"); }这段逻辑放在 OrderService 里,所有修改订单状态的入口都先调用它。状态机校验在答辩时也是很好的加分讲点,说明你考虑到了越权操作和业务完整性。
6. 答辩演示与论文收尾:让评委快速看懂你的系统
6.1 演示路径设计:两分钟讲完核心流程
答辩演示不要一上来就展示代码,评委更关心系统能不能跑通。我建议走一条固定路径:打开小程序首页,展示菜品列表和轮播图;加两道菜到购物车,进入购物车页修改数量;提交订单,展示订单状态从"待付款"变为"待制作";打开订单列表展示历史订单。整个过程控制在 90 秒到两分钟。提前把后端服务启动好、数据库跑起来,别在答辩现场等 Tomcat 启动,这是血泪经验。
6.2 论文结构与写作节奏
论文的骨架按软件工程标准来:可行性分析、需求分析、系统设计、系统实现、系统测试。需求分析里画用例图,系统设计里放数据库 ER 图和表结构,实现部分按模块贴关键代码,测试部分写测试用例表。代码不要整段贴,选核心的逻辑,比如登录接口、下单的金额计算、状态机校验,每个贴 20 到 30 行并加注释说明设计思路。论文的"系统测试"章节容易被忽略,其实很简单,写一张功能测试用例表,列清测试步骤、输入数据、预期结果、实际结果,页数就起来了。
6.3 答辩高频问题与回答角度
三个必被问的问题,提前准备回答角度。第一个是"为什么用 SSM 不用 Spring Boot",回答:课程体系用的 SSM,可以用 XML 和配置更直观理解 Spring 的核心机制,Spring Boot 是对 SSM 的封装,使用 SSM 更能体现基础掌握。第二个是"订单并发怎么办",回答:当前场景是单体应用,MySQL 的事务保证数据一致性,后续可以引入分布式锁或把订单号生成改为发号器。第三个是"openid 是什么",回答:微信登录时通过 code 换取的该用户在当前小程序内的唯一标识,服务端保存用户状态用它。答出这三点,答辩基本稳了。
这套点餐系统做完之后我最大的感受是,毕设不是越花哨越好,而是每个模块都要能讲清楚"为什么这么做"。数据库表设计、订单状态机、登录态流程,这三个点是整套系统的骨架,也是答辩时评委最常下钻的地方。把这三个讲透了,比堆一堆炫技的代码更有说服力。希望帮到你,做完这套方案之后,你会发现微信点餐这个方向后续还能延伸出商家端、取餐叫号、数据分析报表,底子打好了,这些扩展都只是时间问题。
本文还有配套的精品资源,点击获取