简介:本资源为校园外卖平台小程序毕业设计完整项目包,面向计算机相关专业需要完成毕设的学生及Java全栈入门者。项目基于微信小程序、SSM框架与MySQL数据库开发,划分管理员、用户、商家三种角色,涵盖个人中心、用户与商家管理、菜品分类与信息管理、购买菜品、订单信息与订单领取、系统管理等模块,商家可维护菜品并查看订单,用户可浏览购买菜品并查询订单,具备完整业务闭环。压缩包共946个文件,约28.48MB,包含113个java后端源码、119个vue前端页面、121个js脚本、38个wxss与37个wxml小程序页面、2个sql数据库脚本,以及png、svg、jpg等界面素材,另附毕业论文与mp4视频演示,便于对照理解系统结构与运行流程。目前已有295人学习下载,适合作为毕设参考、课程设计模板或SSM与小程序联调的学习案例。
1. 校园外卖小程序毕设:从选题到跑通,一套 SSM 后端到底要写多少东西
每年到了毕设季,问得最多的就是「校园外卖平台小程序」这个题目还能不能做、怎么做才不像烂大街的模板。我前后带过几届学生做这类系统,也帮人改过从网上直接扒下来的源码,说句实在话:这个题目的技术栈——微信小程序 + SSM + MySQL——本身没有任何问题,问题出在大部分人只把它当成一个「能跑就行」的作业,结果答辩时被问三句就露馅。这篇笔记不讲虚的,就按一个真实可交付的校园外卖系统来拆:小程序端要几个页面、SSM 后端要几张表、订单状态怎么流转、数据库怎么设计才不会在答辩时被追问到哑口。适合正在做计算机毕业设计、选了 Java 方向又想做点移动端东西的人,也适合已经拿到一份源码但看不懂里面在干什么、想自己重写一遍的人。读完你至少能判断:手上这份东西值不值得改,还是推倒重来更快。
2. 先想清楚:校园外卖和美团之间差的那几个功能点
2.1 为什么不能照搬通用外卖系统的表结构
通用外卖平台要考虑骑手调度、多商家结算、跨区域配送,表结构里一堆delivery_zone、settlement_bill之类的字段。校园场景完全不是这个逻辑:商家就是食堂窗口或者校内小店,配送基本靠学生兼职或者自提,营业时间跟着课表走。如果你直接把美团那套表结构抄过来,会出现大量永远为空的字段,答辩老师一眼就能看出来是拼凑的。
我一般建议把核心实体压缩到六张表:用户表、商家表、菜品表、订单表、订单明细表、地址表。其中地址表在校园场景里可以简化成「宿舍楼 + 房间号」两个字段,不需要省市区三级联动。订单表里最关键的字段是order_status,它决定了整个系统的复杂度。
2.2 订单状态机:整个系统最容易翻车的地方
校园外卖的订单状态比你想的要绕。一个典型流程是:待支付 → 已支付/待接单 → 已接单/备餐中 → 待取餐/配送中 → 已完成,另外还有已取消和已退款两个终态。很多从网上下的源码只写了三四个状态,结果测试的时候发现「商家拒单」这个场景没法处理。
public enum OrderStatus { PENDING_PAYMENT(0, "待支付"), PAID(1, "已支付待接单"), ACCEPTED(2, "已接单备餐中"), DELIVERING(3, "配送中"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"), REFUNDED(6, "已退款"); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 根据code反查枚举,用于数据库读取后转换 public static OrderStatus fromCode(int code) { for (OrderStatus s : values()) { if (s.code == code) return s; } throw new IllegalArgumentException("未知订单状态: " + code); } }这段枚举的关键在于fromCode方法。数据库里存的是 int,Java 里用的是枚举,如果没有这个转换方法,你会在 Service 层写一堆if (status == 1)的魔法数字,后期加状态时改到崩溃。参数说明:code从 0 开始连续编号,方便数据库用 tinyint 存储;desc直接用于小程序端展示,省去前端再维护一份映射表。
2.3 小程序端页面清单与 SSM 接口的对应关系
很多人做毕设是先把小程序页面画出来,再倒推后端接口,结果接口设计得七零八落。正确的顺序是先定接口,再画页面。下面这张表是我带学生时用的标准对照,你可以直接拿去改:
| 小程序页面 | 核心接口 | 请求方式 | 关键参数 |
|---|---|---|---|
| 首页商家列表 | /api/shop/list | GET | page, size, keyword |
| 商家详情+菜品 | /api/dish/listByShop | GET | shopId |
| 购物车 | 本地存储,不调接口 | - | - |
| 提交订单 | /api/order/create | POST | shopId, items[], addressId |
| 订单列表 | /api/order/listByUser | GET | userId, status, page |
| 订单详情 | /api/order/detail | GET | orderId |
| 个人中心 | /api/user/info | GET | userId |
这张表的价值在于:你拿着它去写 Controller 的时候,每个方法对应哪个页面一目了然,不会出现「这个接口到底给谁用」的困惑。另外注意购物车放在本地存储,这是校园外卖的常见做法,因为用户量小、不需要跨设备同步,省掉一张购物车表能少写不少代码。
3. 把 SSM 后端跑起来:从建表到第一个接口返回 JSON
3.1 数据库建表:六个核心表的字段与索引
先给建表语句,再说为什么这么建。以下以 MySQL 8.0 为例,字符集统一用utf8mb4,排序规则utf8mb4_general_ci。
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信openid', `nickname` VARCHAR(64) DEFAULT NULL, `avatar_url` VARCHAR(255) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `dorm_building` VARCHAR(32) DEFAULT NULL COMMENT '宿舍楼', `dorm_room` VARCHAR(16) DEFAULT NULL COMMENT '房间号', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `shop` ( `id` INT NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, `logo_url` VARCHAR(255) DEFAULT NULL, `location` VARCHAR(128) DEFAULT NULL COMMENT '校内位置描述', `open_time` VARCHAR(32) DEFAULT NULL COMMENT '营业时间段', `status` TINYINT DEFAULT 1 COMMENT '1营业 0休息', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `dish` ( `id` INT NOT NULL AUTO_INCREMENT, `shop_id` INT NOT NULL, `name` VARCHAR(64) NOT NULL, `price` DECIMAL(8,2) NOT NULL, `image_url` VARCHAR(255) DEFAULT NULL, `stock` INT DEFAULT 0, `status` TINYINT DEFAULT 1 COMMENT '1上架 0下架', PRIMARY KEY (`id`), KEY `idx_shop_id` (`shop_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` INT NOT NULL, `shop_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `order_status` TINYINT NOT NULL DEFAULT 0, `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `order_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` INT NOT NULL AUTO_INCREMENT, `order_id` INT NOT NULL, `dish_id` INT NOT NULL, `dish_name` VARCHAR(64) NOT NULL COMMENT '冗余存名称,防止菜品改名', `price` DECIMAL(8,2) NOT NULL, `quantity` INT NOT NULL, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `address` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `contact_name` VARCHAR(32) NOT NULL, `contact_phone` VARCHAR(20) NOT NULL, `dorm_building` VARCHAR(32) NOT NULL, `dorm_room` VARCHAR(16) NOT NULL, `is_default` TINYINT DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个设计点值得展开说。第一,orders表用order_no而不是自增 id 作为业务主键对外暴露,这是为了避免用户通过订单号推测出系统总单量。第二,order_item里冗余了dish_name和price,因为菜品可能改价或下架,订单历史必须保留快照。第三,idx_user_status这个联合索引是专门为「查我的订单列表」这个高频查询建的,没有它的话订单量一上来查询就会明显变慢。
3.2 SSM 整合配置:三个 XML 文件到底在配什么
SSM 的配置是新手最容易卡住的地方。我见过太多人从网上复制了三份 XML,跑起来报错却不知道从哪查。这里把每个文件的作用说清楚。
applicationContext.xml管的是 Spring 容器本身,核心是数据源和 MyBatis 的整合:
<!-- 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/campus_takeout?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"/> <property name="username" value="root"/> <property name="password" value="你的密码"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> </bean> <!-- MyBatis SqlSessionFactory --> <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.campus.entity"/> </bean> <!-- 扫描 Mapper 接口 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.campus.mapper"/> </bean>spring-mvc.xml管的是 Web 层,核心是注解驱动和 JSON 转换:
<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter"> <property name="supportedMediaTypes"> <list> <value>application/json;charset=UTF-8</value> </list> </property> </bean> </mvc:message-converters> </mvc:annotation-driven> <context:component-scan base-package="com.campus.controller"/> <!-- 静态资源放行,小程序开发阶段可能用到 --> <mvc:default-servlet-handler/>web.xml是入口,负责加载上面两个容器并配置 DispatcherServlet。这三个文件的关系是:web.xml启动时先加载applicationContext.xml创建父容器(Service 和 DAO),再加载spring-mvc.xml创建子容器(Controller)。子容器能访问父容器的 Bean,反过来不行——这就是为什么 Controller 能注入 Service,但 Service 里拿不到 Controller。
3.3 第一个能返回 JSON 的接口:订单创建
配置跑通后,先写一个最核心的接口验证链路:创建订单。这个接口涉及参数校验、库存扣减、订单号生成、事务控制,能跑通它基本说明后端没问题了。
@RestController @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") public Result<Map<String, Object>> create(@RequestBody OrderCreateDTO dto) { // 参数校验 if (dto.getUserId() == null || dto.getItems() == null || dto.getItems().isEmpty()) { return Result.error("参数不完整"); } try { String orderNo = orderService.createOrder(dto); Map<String, Object> data = new HashMap<>(); data.put("orderNo", orderNo); return Result.success(data); } catch (BizException e) { return Result.error(e.getMessage()); } } }Service 层的核心逻辑:
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Autowired private DishMapper dishMapper; @Autowired private OrderItemMapper orderItemMapper; @Override @Transactional(rollbackFor = Exception.class) public String createOrder(OrderCreateDTO dto) { // 1. 校验库存并扣减 BigDecimal total = BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { Dish dish = dishMapper.selectById(item.getDishId()); if (dish == null || dish.getStatus() == 0) { throw new BizException("菜品已下架: " + item.getDishId()); } if (dish.getStock() < item.getQuantity()) { throw new BizException("库存不足: " + dish.getName()); } // 乐观锁扣库存,防止超卖 int affected = dishMapper.reduceStock(item.getDishId(), item.getQuantity()); if (affected == 0) { throw new BizException("库存扣减失败,请重试"); } total = total.add(dish.getPrice().multiply(new BigDecimal(item.getQuantity()))); } // 2. 生成订单号:时间戳 + 用户ID后四位 String orderNo = System.currentTimeMillis() + String.format("%04d", dto.getUserId() % 10000); // 3. 插入订单主表 Orders order = new Orders(); order.setOrderNo(orderNo); order.setUserId(dto.getUserId()); order.setShopId(dto.getShopId()); order.setTotalAmount(total); order.setOrderStatus(OrderStatus.PENDING_PAYMENT.getCode()); orderMapper.insert(order); // 4. 插入订单明细 for (OrderItemDTO item : dto.getItems()) { OrderItem oi = new OrderItem(); oi.setOrderId(order.getId()); oi.setDishId(item.getDishId()); oi.setQuantity(item.getQuantity()); orderItemMapper.insert(oi); } return orderNo; } }reduceStock对应的 Mapper XML 是:
<update id="reduceStock"> UPDATE dish SET stock = stock - #{quantity} WHERE id = #{dishId} AND stock >= #{quantity} </update>这里的关键是WHERE stock >= #{quantity}这个条件。它利用 MySQL 的行锁实现了乐观锁效果:如果库存不够,update 影响行数为 0,Service 层就抛异常回滚。这比先 select 再 update 要安全得多,高并发下不会超卖。参数说明:@Transactional(rollbackFor = Exception.class)确保任何异常都回滚,默认只回滚 RuntimeException,加上rollbackFor更保险。
4. 小程序端对接:登录、请求封装和列表加载更多
4.1 微信登录换 openid 的正确姿势
小程序端不能直接拿用户信息,必须先调wx.login拿 code,传给后端换 openid。后端用 code 调微信接口,拿到 openid 后查库,没有就注册。
// 小程序端 app.js App({ globalData: { userId: null, openid: null }, onLaunch() { wx.login({ success: (res) => { if (res.code) { wx.request({ url: 'http://localhost:8080/api/user/login', method: 'POST', data: { code: res.code }, success: (resp) => { if (resp.data.code === 200) { this.globalData.userId = resp.data.data.userId; this.globalData.openid = resp.data.data.openid; } } }); } } }); } });后端处理逻辑:用 code 加上小程序的 appid 和 secret 去调微信的jscode2session接口,返回 openid 和 session_key。注意 appid 和 secret 不要硬编码在代码里,放到配置文件里。另外这个接口必须在服务端调用,不能在小程序端直接调,否则 secret 会泄露。
4.2 请求封装:统一处理 token 和错误提示
每个页面都写一遍wx.request是灾难。封装一个request.js:
const BASE_URL = 'http://localhost:8080'; function request(options) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'content-type': 'application/json' }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };参数说明:BASE_URL在开发阶段指向本地,上线要改成备案域名。code === 200是后端统一返回格式的约定,建议所有接口都返回{code, message, data}三段式结构,前端只判断 code 就行。
4.3 订单列表分页加载:onReachBottom 与防重复请求
校园外卖的订单列表用分页加载是标配。小程序里用onReachBottom触发加载下一页,但必须加一个loading标志位防止重复请求。
Page({ data: { orders: [], page: 1, size: 10, hasMore: true, loading: false }, onLoad() { this.loadOrders(); }, onReachBottom() { if (!this.data.hasMore || this.data.loading) return; this.setData({ page: this.data.page + 1 }); this.loadOrders(); }, loadOrders() { this.setData({ loading: true }); request({ url: '/api/order/listByUser', data: { userId: getApp().globalData.userId, page: this.data.page, size: this.data.size } }).then((list) => { this.setData({ orders: this.data.orders.concat(list), hasMore: list.length === this.data.size, loading: false }); }).catch(() => { this.setData({ loading: false }); }); } });hasMore的判断逻辑是:如果返回的记录数等于 pageSize,说明可能还有下一页;小于 pageSize 则说明到底了。这个判断比让后端返回 total 更简单,校园场景数据量不大,够用。
5. 避坑与排查:那些让答辩当场卡住的细节
5.1 中文乱码:从数据库到小程序的完整链路
现象:小程序端显示菜品名称是问号或者方块。原因通常出在三个地方之一:数据库字符集不是 utf8mb4、JDBC 连接串没加 characterEncoding、或者 Tomcat 的 URIEncoding 没配。解决顺序是:先确认建表时用了DEFAULT CHARSET=utf8mb4,再检查 JDBC url 里有没有characterEncoding=utf8mb4,最后看 Tomcat 的 server.xml 里 Connector 有没有URIEncoding="UTF-8"。三个都对了基本不会乱码。
5.2 订单号重复:时间戳方案的隐患
现象:极少数情况下插入订单报唯一键冲突。原因是用System.currentTimeMillis()生成订单号,同一毫秒内如果有两个请求就会重复。解决:在时间戳后面再加一个随机数或者用AtomicLong自增序列。更稳妥的做法是用雪花算法,但毕设场景加个 3 位随机数就够了。
5.3 小程序请求本地接口失败:域名校验与开发者工具设置
现象:开发者工具里请求http://localhost:8080报错「不在以下 request 合法域名列表中」。原因:小程序默认只允许 https 域名。解决:在微信开发者工具右上角「详情」→「本地设置」里勾选「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」。注意这个选项只在开发阶段用,上线必须配 https 域名。
5.4 MyBatis 返回 null:字段名与属性名映射不上
现象:查询返回的对象里某些字段是 null,但数据库里明明有值。原因:数据库字段用下划线命名(如create_time),Java 属性用驼峰(createTime),MyBatis 默认不会自动转换。解决:在applicationContext.xml的 SqlSessionFactory 里加<property name="configuration" ref="mybatisConfig"/>,然后配置mapUnderscoreToCamelCase为 true。或者在每个 Mapper XML 里手写 resultMap,但那样太累。
5.5 事务不生效:Service 内部方法直接调用
现象:在 Service 的 A 方法里直接调 B 方法,B 方法上标了@Transactional但回滚不生效。原因:Spring 的事务是基于代理的,同一个类内部方法调用不走代理对象。解决:把 B 方法挪到另一个 Service 里,或者通过AopContext.currentProxy()获取代理对象再调。毕设里最简单的做法就是拆到两个 Service。
6. 让毕设多拿十分:论文里怎么把 SSM 和微信小程序写出技术深度
6.1 论文技术章节的写法:别只贴代码
很多人写论文的技术实现章节就是大段贴代码,答辩老师翻两页就不想看了。正确的写法是:先画一张系统架构图(用 Visio 或者 draw.io 画,别用截图),然后按「表现层 → 业务层 → 数据层」三层来写。表现层讲小程序的页面结构和请求封装,业务层讲 Service 的职责划分和事务边界,数据层讲表结构设计和索引优化。每一层配一段 200 字左右的说明,代码只贴关键片段,比如订单状态枚举和库存扣减的 SQL。
6.2 性能测试数据:用 JMeter 跑一组有说服力的数字
毕设里加一组性能测试数据会让论文看起来扎实很多。用 JMeter 对订单创建接口做 100 并发测试,记录平均响应时间和吞吐量。我一般会跑三组:无索引、加索引、加索引+连接池调优,然后做对比。下面是一个参考表格格式:
| 场景 | 并发数 | 平均响应时间(ms) | 吞吐量(TPS) | 错误率 |
|---|---|---|---|---|
| 无索引 | 100 | 850 | 118 | 0% |
| 加 idx_user_status | 100 | 320 | 312 | 0% |
| 加索引+连接池 maxActive=50 | 100 | 180 | 555 | 0% |
这组数据不需要多精确,但能说明你确实做了优化,而不是随便写了个能跑的代码就交差。
6.3 视频演示的录制技巧:三分钟讲清楚核心流程
视频演示不要从头到尾录一遍所有页面,老师没耐心看。三分钟足够:前 30 秒展示小程序首页和商家列表,中间 90 秒完整走一遍「选菜 → 下单 → 支付 → 商家接单 → 订单状态变化」,最后 60 秒打开数据库客户端展示订单表和明细表的数据。录制用 OBS 或者系统自带录屏都行,关键是提前把测试数据准备好,别录到一半发现购物车是空的。
6.4 源码整理:让老师能跑起来比什么都重要
最后说一个血泪经验:答辩前一定要把源码整理成一个能直接导入运行的工程。我见过太多人代码在自己电脑上跑得好好的,换台机器就各种报错。整理清单:数据库导出 sql 文件(包含建表+测试数据)、配置文件里的数据库密码改成通用的、README 写清楚导入步骤和依赖版本。别小看这个,老师如果跑不起来你的代码,印象分直接砍半。
希望帮到你。
本文还有配套的精品资源,点击获取