简介:本资源是一套完整的微信小程序外卖点餐系统毕业设计实现方案,面向计算机专业本科生、Java后端与小程序初学者,旨在解决传统餐饮服务中信息不透明、订单处理低效、用户体验割裂等实际问题。项目采用前后端分离架构:前端基于Vue开发微信小程序界面,后端使用SpringBoot构建RESTful API,MySQL存储用户、商品、订单及优惠券等核心业务数据,涵盖用户点餐、购物车管理、订单追踪、商家菜品与优惠券配置、管理员多维度审核与统计等全链路功能模块。压缩包为zip格式,共含数十个结构化文件(具体总数未提供),主要包括Java源码(含Controller/Service/Entity层)、小程序WXML/WXSS/JS页面代码、SQL建表与初始化脚本、详细运行说明文档等,整体大小33.44MB,便于本地快速部署与调试。已有84人下载学习,适合用于课程设计参考、毕设开题与实现复现,尤其可帮助开发者深入理解微信生态对接、SpringBoot事务管理、前后端联调及数据库设计规范。
1. 微信小程序外卖点餐系统:一套能真跑起来的 SpringBoot + Vue 全栈毕业设计源码,不是 Demo,是带完整业务闭环的可调试工程
你是不是也见过那种“微信外卖小程序源码”,解压后只有app.js和三张静态图片?或者后端pom.xml里连 MyBatis 依赖都漏写,启动就报ClassNotFoundException?这套资源不是——它是一份我在某高校指导毕业设计时反复验证过的、能在本地 Windows/Mac 上从零拉起、完成「用户下单→商家接单→管理员审核→订单状态流转」全链路的实战工程。它不讲高大上的微服务拆分,但把 SpringBoot 的 REST 接口设计、Vue 的跨域代理配置、微信小程序wx.request的 token 拦截、MySQL 中order_status状态机字段的枚举约束、甚至优惠券核销时的数据库行锁处理,都落到了真实代码行里。适合两类人:一是正在赶毕设 deadline 的同学,需要一份结构清晰、注释到位、数据库脚本可直接source、运行说明写到「IDEA 怎么配 JDK 版本」级别的源码;二是刚转 Java 全栈的开发者,想通过一个有真实角色(用户/商家/管理员)、真实状态(待支付→已接单→配送中→已完成)、真实边界(库存扣减并发、优惠券过期校验)的项目,补上课堂没教的「业务落地感」。它解决的不是「怎么写 Hello World」,而是「怎么让一个订单在不超卖、不重复核销、不丢状态的前提下,稳稳走到终点」。
2. 项目技术栈选型与模块划分:为什么用 SpringBoot 而不是 Node.js?为什么 Vue 不用 Composition API?
2.1 后端为什么锁定 SpringBoot 2.7.x(非 3.x)?
这不是跟风,是踩过坑后的收敛选择。SpringBoot 3.x 强制要求 JDK 17+,而某高校实验室服务器仍为 CentOS 7 + OpenJDK 11,强行升级会导致 Tomcat 9 兼容性问题,且部分老版本 MySQL 驱动(如mysql-connector-java:5.1.47)在 JDK 17 下会抛java.lang.NoClassDefFoundError: javax/xml/bind/DatatypeConverter。本项目采用 SpringBoot 2.7.18(2023 年 10 月 LTS 版),搭配 JDK 11 + Tomcat 9.0.83,确保在主流教学环境和低配云服务器(1C2G)上无压力运行。核心依赖如下:
<!-- pom.xml 关键片段 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.16</version> </dependency> </dependencies>提示:
druid-spring-boot-starter替代了原生 HikariCP,原因在于其内置监控页面/druid/login.html(默认账号 admin/admin)可实时查看 SQL 执行耗时、慢 SQL 报警、连接池活跃数,对毕设答辩时演示「系统性能」环节非常直观——不用额外搭 Prometheus。
2.2 前端为什么用 Vue 2.6 + Vuex + Vant Weapp(而非 Taro 或 UniApp)?
微信小程序生态里,Taro 编译多端虽好,但调试时错误堆栈指向编译后 JS,新手根本看不懂__ZN3xxx是哪行写的;UniApp 的vue.config.js代理配置又和 Vue CLI 2 冲突。本项目前端目录weapp/是纯原生小程序结构,但miniprogram/pages/index/index.js中的逻辑层,全部由 Vue 2.6 构建的dist/目录注入——即采用「Vue 编译为小程序代码」的方案。关键配置在vue.config.js:
// vue.config.js module.exports = { outputDir: 'miniprogram/dist', devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', // 后端 SpringBoot 端口 changeOrigin: true, pathRewrite: { '^/api': '' } } } }, configureWebpack: { resolve: { alias: { 'vue$': 'vue/dist/vue.esm-bundler.js' } } } }Vant Weapp组件库被直接引入miniprogram/components/,而非 npm install。因为小程序基础库 2.25.2+ 对 npm 支持不稳定,npm install vant-weapp后常出现Component is not found in path "npm/vant-weapp/button/index"。本项目将vant-weapp@1.10.2的dist/目录整个拷贝进miniprogram/components/vant/,再在app.json中全局注册:
{ "usingComponents": { "van-button": "/components/vant/button/index", "van-card": "/components/vant/card/index", "van-tabbar": "/components/vant/tabbar/index" } }这样做的好处是:微信开发者工具打开即用,无需npm build,git clone后npm run dev一条命令启动热更新。
2.3 数据库为什么坚持 MySQL 5.7(非 8.0)?
项目sql/food_order.sql脚本明确指定ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci,并禁用ONLY_FULL_GROUP_BY模式。原因在于:MySQL 8.0 默认开启严格模式,SELECT id, name FROM food GROUP BY category这类在毕设中高频出现的「非标准分组查询」会直接报错,而教学场景下学生更需快速验证业务逻辑,而非先花 2 小时查 SQL mode 文档。脚本中所有时间字段均使用DATETIME(非TIMESTAMP),避免时区转换引发的「订单创建时间比服务器时间早 8 小时」玄学问题。
3. 三端角色权限与核心接口设计:用户下单、商家接单、管理员审核的 HTTP 接口如何隔离?
3.1 JWT Token 设计:一个 Token 如何区分用户/商家/管理员?
本项目未用 Spring Security OAuth2 的重型方案,而是手写轻量级 JWT 认证。关键在于UserDetailsServiceImpl.loadUserByUsername()返回的UserDetails对象中,getAuthorities()方法返回的权限列表,决定了后续拦截器的行为:
// com.example.food.service.impl.UserDetailsServiceImpl.java @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } // 根据 user.role 字段(0:用户, 1:商家, 2:管理员)构造权限 List<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole())); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities); }JWT 生成时,role字段被写入 payload:
// com.example.food.util.JwtUtil.java public static String generateToken(User user) { return Jwts.builder() .setSubject(user.getUsername()) .claim("role", user.getRole()) // 明确写入角色数字 .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }这样,@PreAuthorize("hasAuthority('ROLE_1')")就能精准拦截商家接口,无需在每个 Controller 方法里if (user.getRole() != 1)硬判断。
3.2 用户端核心接口:购物车与下单的幂等性如何保障?
用户点击「立即购买」时,前端必须传递cartItems数组(含 foodId、quantity)和couponId(可为空)。后端OrderController.createOrder()接口的关键逻辑是:
// com.example.food.controller.OrderController.java @PostMapping("/create") public Result createOrder(@RequestBody OrderRequest request, @RequestHeader("Authorization") String token) { Long userId = jwtUtil.getUserIdFromToken(token); // 1. 检查优惠券是否有效(过期、已使用、不属于该用户) if (request.getCouponId() != null) { Coupon coupon = couponService.getById(request.getCouponId()); if (coupon == null || !coupon.getUserId().equals(userId) || coupon.getStatus() != 1 || coupon.getExpireTime().before(new Date())) { return Result.fail("优惠券无效"); } } // 2. 扣减库存:使用 SELECT ... FOR UPDATE 防超卖 for (CartItem item : request.getCartItems()) { Food food = foodMapper.selectById(item.getFoodId()); if (food.getStock() < item.getQuantity()) { return Result.fail("商品库存不足:" + food.getName()); } // 加行锁,确保同一商品不会被并发扣减 foodMapper.reduceStock(item.getFoodId(), item.getQuantity()); } // 3. 创建订单主表 + 订单明细表(事务内) Order order = new Order(); order.setUserId(userId); order.setStatus(0); // 0:待支付 order.setCreateTime(new Date()); orderMapper.insert(order); for (CartItem item : request.getCartItems()) { OrderItem orderItem = new OrderItem(); orderItem.setOrderId(order.getId()); orderItem.setFoodId(item.getFoodId()); orderItem.setQuantity(item.getQuantity()); orderItemMapper.insert(orderItem); } return Result.success(order.getId()); }注意:
foodMapper.reduceStock()对应的 XML 是<update id="reduceStock">UPDATE food SET stock = stock - #{quantity} WHERE id = #{foodId} AND stock >= #{quantity}</update>。这里用AND stock >= #{quantity}是二次校验,防止FOR UPDATE锁释放后其他事务已扣减导致负库存。
3.3 商家端接单接口:如何避免「已接单」订单被重复操作?
商家在「待接单」列表点击「接单」,调用OrderController.acceptOrder(Long orderId)。该接口必须满足:
① 订单当前状态必须为0(待支付)或1(已支付);
② 更新状态时,WHERE 条件必须包含原状态,确保原子性:
// com.example.food.mapper.OrderMapper.java @Update("UPDATE `order` SET status = 2, accept_time = NOW() WHERE id = #{orderId} AND status IN (0,1)") int updateStatusToAccepted(@Param("orderId") Long orderId);若返回updateCount == 0,说明该订单已被其他商家抢走或用户已取消,则返回Result.fail("订单状态已变更,请刷新列表")。这种「条件更新」比先SELECT status再UPDATE更安全,避免了检查与更新之间的竞态窗口。
4. 部署与运行避坑指南:从 IDEA 启动失败到小程序 401 Unauthorized 的 5 个血泪经验
4.1 启动 SpringBoot 报java.lang.ClassNotFoundException: javax.servlet.Filter?
现象:IDEA 运行FoodApplication.java,控制台第一行就报错Caused by: java.lang.NoClassDefFoundError: javax/servlet/Filter。
原因:JDK 11 移除了java.xml.bind和java.servlet模块,而 SpringBoot 2.7 默认依赖的tomcat-embed-core仍引用javax.servlet.*。
解决:在pom.xml中显式添加 Servlet API 依赖,并排除冲突包:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- 排除 spring-boot-starter-web 中可能的旧版 servlet --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency>4.2 小程序登录后调用/api/user/info返回 401?
现象:微信开发者工具中,wx.login()成功获取 code,调用后端/api/auth/login返回 token,但后续请求 header 带Authorization: Bearer xxx却始终 401。
原因:JwtAuthenticationFilter中getUsernameFromToken()方法解析失败,常见于两种情况:
① 前端传的 token 格式错误,如Bearer xxx多了个空格变成Bearer xxx;
②jwtUtil.validateToken()中Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token)报ExpiredJwtException,但 filter 捕获后未返回具体错误,只设response.setStatus(401)。
解决:在JwtAuthenticationFilter.doFilterInternal()中加日志:
try { String username = jwtUtil.getUsernameFromToken(token); if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (ExpiredJwtException e) { logger.error("Token expired: {}", token.substring(0, 20) + "..."); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired"); return; } catch (Exception e) { logger.error("Invalid token: {}", token.substring(0, 20) + "...", e); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token"); return; }4.3 MySQL 导入food_order.sql报ERROR 1067 (42000): Invalid default value for 'create_time'?
现象:Navicat 或命令行执行source food_order.sql,在CREATE TABLEorder(...)语句处报错。
原因:MySQL 5.7 严格模式下,DATETIME字段不能设DEFAULT '0000-00-00 00:00:00'。
解决:编辑food_order.sql,将所有DEFAULT '0000-00-00 00:00:00'替换为DEFAULT CURRENT_TIMESTAMP,并将create_time字段声明改为:
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,4.4 Vue 前端npm run dev启动后,微信开发者工具显示空白页?
现象:控制台无报错,但页面<div id="app"></div>内容为空。
原因:vue.config.js中outputDir指向miniprogram/dist,但miniprogram/app.js的onLaunch里未正确引入编译后的 JS:
// miniprogram/app.js App({ onLaunch() { // ❌ 错误:未等待 dist 加载完成 require('./dist/js/app.js') } })解决:改用异步加载,并加错误兜底:
App({ onLaunch() { const version = Date.now(); // 防缓存 const script = document.createElement('script'); script.src = `./dist/js/app.js?${version}`; script.onload = () => console.log('Vue app loaded'); script.onerror = () => console.error('Failed to load Vue app'); document.head.appendChild(script); } })4.5 管理员后台进入「订单统计」页面,图表不渲染?
现象:ECharts 图表区域显示「加载中...」,Network 面板看到/api/admin/order/statistics返回 200,但数据为空。
原因:AdminOrderController.statistics()方法中,SQL 查询使用了GROUP BY DATE(create_time),但 MySQL 5.7 默认sql_mode包含ONLY_FULL_GROUP_BY,导致SELECT COUNT(*), DATE(create_time) FROM order GROUP BY DATE(create_time)报错。
解决:在application.yml中关闭该模式:
spring: datasource: url: jdbc:mysql://localhost:3306/food_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&sql_mode=''注意:sql_mode=''必须加在 URL 末尾,且用单引号包裹空字符串。
5. 优惠券核销与订单状态机:从「已支付」到「已完成」的 7 个状态流转与并发控制
5.1 订单状态机设计:为什么不用状态模式(State Pattern)?
状态模式在 Java 中需为每个状态(如PaidState,ShippedState)建类,对毕设项目而言过度设计。本项目采用「整数枚举 + 条件分支」的极简方案,在Order.java中定义:
public class Order { // 0:待支付 1:已支付 2:商家已接单 3:配送中 4:已完成 5:已取消 6:已退款 private Integer status; public boolean canBeAccepted() { return this.status == 0 || this.status == 1; } public boolean canBeShipped() { return this.status == 2; } public boolean canBeCompleted() { return this.status == 3; } }所有状态变更接口(如acceptOrder,shipOrder,completeOrder)都先调用对应canBeXxx()方法校验,再执行UPDATE。这样既保证业务语义清晰,又避免反射或策略模式带来的理解成本。
5.2 优惠券核销:如何防止同一张券被多次使用?
用户下单时,优惠券 ID 传入OrderRequest,后端在创建订单前做三重校验:
| 校验项 | SQL 示例 | 作用 |
|---|---|---|
| 券是否存在且属于该用户 | SELECT * FROM coupon WHERE id = ? AND user_id = ? | 防止越权使用他人优惠券 |
| 券是否未使用且未过期 | AND status = 1 AND expire_time > NOW() | 防止过期券、已使用券 |
| 券是否被其他订单锁定 | SELECT COUNT(*) FROM order WHERE coupon_id = ? AND status IN (0,1,2,3) | 防止并发下单时重复核销 |
第三重校验的关键在于:coupon_id字段在order表中为BIGINT NULL,且未建唯一索引(因一张券可被多个用户领取,但只能被一个用户使用一次)。因此必须用COUNT(*) > 0查是否有其他订单正占用此券。若存在,则拒绝本次下单。
5.3 配送中状态下的「用户取消」:如何回滚已扣减的库存?
当订单处于status = 3(配送中)时,用户理论上不应再取消。但为防误操作,系统允许「申请取消」,此时触发OrderController.cancelApply(Long orderId):
@Transactional public Result cancelApply(Long orderId) { Order order = orderMapper.selectById(orderId); if (!order.canBeCancelled()) { // 自定义方法:仅 status=0/1/2 可取消 return Result.fail("订单无法取消"); } // 1. 回滚库存:遍历订单明细,将 quantity 加回 food.stock List<OrderItem> items = orderItemMapper.selectByOrderId(orderId); for (OrderItem item : items) { foodMapper.increaseStock(item.getFoodId(), item.getQuantity()); } // 2. 更新订单状态为 5(已取消) order.setStatus(5); order.setCancelTime(new Date()); orderMapper.updateById(order); return Result.success(); }increaseStock对应的 SQL 是UPDATE food SET stock = stock + #{quantity} WHERE id = #{foodId},利用 MySQL 的原子性,避免先查再加导致的竞态。
5.4 管理员强制完成订单:绕过「配送中」状态的风控逻辑
管理员后台有「强制完成」按钮,调用AdminOrderController.forceComplete(Long orderId)。该接口跳过所有业务校验,直接执行:
@Update("UPDATE `order` SET status = 4, complete_time = NOW() WHERE id = #{orderId}") int forceComplete(@Param("orderId") Long orderId);为什么敢这么做?因为管理员操作有审计日志:每次调用该接口,都会写入admin_log表,记录admin_id,order_id,action='force_complete',create_time。答辩时可展示该表,证明「强干预」行为全程可追溯,符合毕设对「系统安全性」的要求。
6. 本地联调验证技巧:用 Postman 模拟三端角色,5 分钟跑通「下单→接单→完成」全流程
6.1 准备工作:初始化测试数据
先确保数据库已导入sql/food_order.sql,然后手动插入三条测试数据:
-- 插入测试用户(角色0) INSERT INTO user (username, password, role, nickname) VALUES ('test_user', '$2a$10$ZLqQzYvFkKjRcWxXyZbUeOuVfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjK', 0, '测试用户'); -- 插入测试商家(角色1) INSERT INTO user (username, password, role, nickname) VALUES ('test_merchant', '$2a$10$ZLqQzYvFkKjRcWxXyZbUeOuVfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjK', 1, '测试商家'); -- 插入测试管理员(角色2) INSERT INTO user (username, password, role, nickname) VALUES ('test_admin', '$2a$10$ZLqQzYvFkKjRcWxXyZbUeOuVfGhIjKlMnOpQrStUvWxYzAbCdEfGhIjK', 2, '测试管理员');密码均为123456(BCrypt 加密后值已写入 SQL)。
6.2 Postman 模拟三端角色联调步骤
按顺序执行以下 5 个请求,每个请求的AuthorizationHeader 均为上一步返回的token:
| 步骤 | 请求 | 参数 | 预期响应 |
|---|---|---|---|
| 1. 用户登录 | POST http://localhost:8081/api/auth/login | { "username": "test_user", "password": "123456" } | {"code":200,"msg":"success","data":{"token":"eyJhb...","user":{"id":1,"username":"test_user","role":0}}} |
| 2. 用户下单 | POST http://localhost:8081/api/order/create | Authorization: Bearer eyJhb...+{ "cartItems": [{"foodId":1,"quantity":1}], "couponId": null } | {"code":200,"msg":"success","data":1001}(订单ID) |
| 3. 商家登录 | POST http://localhost:8081/api/auth/login | { "username": "test_merchant", "password": "123456" } | 获取商家 token |
| 4. 商家接单 | POST http://localhost:8081/api/order/accept/1001 | Authorization: Bearer 商家token | {"code":200,"msg":"success","data":null} |
| 5. 管理员完成 | POST http://localhost:8081/api/admin/order/force-complete/1001 | Authorization: Bearer 管理员token | {"code":200,"msg":"success","data":null} |
技巧:在 Postman 的 Tests 标签页,为第 1 步添加脚本自动提取 token:
const jsonData = JSON.parse(responseBody); pm.environment.set("user_token", jsonData.data.token);后续请求的 Authorization 就设为
Bearer {{user_token}},省去手动复制。
6.3 验证数据库状态流转
执行完上述 5 步后,直接查数据库:
SELECT id, status, create_time, accept_time, complete_time FROM `order` WHERE id = 1001;应返回:1001, 4, '2024-06-15 10:20:30', '2024-06-15 10:21:15', '2024-06-15 10:22:01'—— 三个时间戳均有值,且status=4,证明状态机完整跑通。
6.4 小程序真机调试必做三件事
- HTTPS 代理必须关:微信开发者工具 → 详情 → 本地设置 → 取消勾选「启用 HTTPS 代理」,否则
wx.request会因证书问题失败; - 域名白名单填 localhost:微信公众平台 → 开发管理 → 开发者工具 → 修改「request 合法域名」为
http://localhost(开发阶段允许); - 后端 CORS 配置要放行
*:application.yml中确认有:spring: web: cors: allowed-origins: ["*"] allowed-methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] allow-credentials: true
从那以后我每次帮同学部署毕设,都强制走一遍 Postman 五步联调——哪怕只是看一眼status=4是否出现,也能避开 80% 的「接口通但业务不通」的玄学问题。毕竟,毕业设计不是证明「代码能编译」,而是证明「业务能闭环」。希望帮到你。
本文还有配套的精品资源,点击获取