
简介面向毕业设计与SSM开发学习场景的校园订餐小程序项目源码基于Java的Spring、SpringMVC、MyBatis框架与MySQL数据库实现配套微信小程序端。系统围绕管理员、用户、商家三大角色设计管理员可在后台管理用户、商家、餐厅、美食类型及系统参数用户在小程序端浏览餐厅与美食操作购物车、下单、收藏和充值商家则维护自己的餐厅和美食信息并处理订单。资源包共1414个文件大小约24.77MB主体包含java后端业务代码、sql数据库初始化脚本、vue管理后台页面、wxml/wxss小程序界面以及大量png、svg图片和bat运行脚本目录划分清晰便于按模块学习与改造。目前已有108人学习下载对于希望参考完整校园订餐业务流程、深入理解SSM框架与微信小程序前后端协作的读者是一份可直接导入运行并二次开发的实用资料。1. 基于SSM的校园订餐小程序一个能跑通全栈的毕业设计骨架“基于SSM的校园订餐小程序”这类压缩包在毕业设计市场里很常见一个微信小程序前端一个 Spring SpringMVC MyBatis 的后端外加一套 MySQL 脚本。它的价值在于完整演示了一个点餐闭环——用户进小程序浏览菜品、加购物车、下单商家/后台管理订单。我见过不少学生拿到源码后第一周就部署成功但一联调就卡在登录态丢失、订单重复提交、真机连不上接口这些地方。我会沿“后端骨架 → 表结构与接口 → 小程序端对接 → 部署与排错 → 性能验证”的路径把一个可复现的 SSM 校园订餐方案讲透。适合想快速搭出可演示系统、又愿意理解底层配置的开发者。2. 用 Maven 搭出 SSM 后端骨架先跑通健康检查接口采用 SSM 而不是 Spring Boot是因为校园订餐这类业务量级并不需要微服务SSM 的 XML 配置反而能看清 Spring 容器、MVC 分发器和 MyBatis 代理之间的关系。接下来我按手工创建 Maven 工程的方式从零搭一个最小后端。2.1 为什么这套组合现在还值得用Spring 管业务对象的生命周期SpringMVC 管 HTTP 请求到 handler 的映射MyBatis 管 SQL 和 Java 对象的转换。三者配合正好覆盖一个订餐系统后端的所有层次controller 层接收小程序端的 JSONservice 层写订单状态流转mapper 层操作数据库。相比 Spring Boot 的自动配置SSM 的手动装配确实繁琐但项目启动后你能清楚看到每个配置文件的加载顺序这对排查“接口 404”很有帮助。另外这类工程最常见的问题是依赖版本不统一Spring 4 和 MyBatis 3.5 混用会导致BindingException。我建议统一用 Spring 5.2.x MyBatis 3.5.x Maven 3.6兼容性和中文教程都比较全。如果你打算用 JDK 11 及以上还得确认javax.annotation依赖还在否则启动时会报ServletException。2.2 从零搭工程pom.xml 与 web.xml 必须写对的三处先建一个标准 Maven WAR 工程groupId自定义比如com.campus.order。核心依赖如下dependencies !-- Spring 核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version5.2.15.RELEASE/version /dependency !-- SpringMVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.2.15.RELEASE/version /dependency !-- MyBatis 与 Spring 整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.22/version /dependency !-- JSON 解析 -- dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependency !-- 连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.6/version /dependency /dependencies这段依赖里mybatis-spring是把 SqlSessionFactory 交给 Spring 管理的关键缺了它会在启动时报 MapperScan 找不到。版本上还要注意 Spring 5 的模块变化Spring 5 把 JDBC 抽到了独立模块很多老教程没写spring-jdbc导致数据库事务代理报ClassNotFoundException: org.springframework.jdbc.datasource.DataSourceTransactionManager。另外 fastjson 建议用带安全修复的版本别去抄 1.2.4 这种旧号码。web.xml 是另一个易错点。我在传统 SSM 工程里只用三组配置DispatcherServlet、ContextLoaderListener、CharacterEncodingFilter。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 !-- 先配置编码过滤器防止中文乱码 -- filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- Spring 容器 -- context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- SpringMVC 前端控制器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping /web-app这里的forceEncoding必须设为 true否则如果请求头里已经带Content-Type过滤器会跳过编码转换新增菜品名存进 MySQL 就变成问号。DispatcherServlet的url-pattern不建议用*.do既然小程序走的是 JSON API用/能让 Controller 里的RequestMapping(api/...)更干净。load-on-startup设为 1是为了让 Tomcat 启动时立即初始化 Spring 容器而不是等第一个请求来才初始化否则你会在第一回请求时看到 5 秒以上的延迟。2.3 启动时验证一个健康检查接口确认三层联动services 和 mappers 先不写复杂业务我习惯先做一个不带数据库的 HealthController快速验证 SpringMVC 配置。创建com.campus.order.controller.HealthControllerController RequestMapping(/api/health) public class HealthController { GetMapping(/ping) ResponseBody public MapString, Object ping() { MapString, Object result new HashMap(); result.put(code, 200); result.put(message, SSM campus ordering service is running); return result; } }编译打包后部署到 Tomcat 8.5/9.0如果applicationContext.xml里没写多余的mvc:annotation-driven/这一步多半会直接通过。需要注意GetMapping要求 Spring 4.3如果你手上的 SSM 模板还是 spring-webmvc 4.2得改用RequestMapping(value /ping, method RequestMethod.GET)。跑通后浏览器访问http://localhost:8080/ssm_order/api/health/ping应返回 JSON。到这里后端空壳已经能启动。真正的订餐业务要从数据库建模开始这是下一章要解决的问题。3. 设计校园订餐的 8 张表从菜单到订单状态机校园订餐业务看着简单但表与表之间的粘合度很容易被低估。很多项目最后交付不了问题就出在订单表和明细表的快照字段上。这一章先画出核心表再给出 MyBatis 联查和订单状态流转的最小实现。3.1 核心表结构与字段清单我习惯把表拆成 8 张用户表、品类表、菜品表、地址表、购物车表、订单主表、订单明细表、评价表。订单生命周期由订单主表的status字段驱动明细表只负责快照菜品名、价格和数量。表名关键字段说明userid, openid, nickname, phone, rolerole 区分用户/商家openid 用于小程序登录categoryid, name, sort, status菜品分类如早餐/午餐dishid, category_id, name, price, image, stock, status库存初始为实际备餐余量cartid, user_id, dish_id, quantity, selected小程序端用本地缓存服务端表做二次校验addressid, user_id, school_building, room_no, phone, is_default校园场景用宿舍楼房间号ordersid, order_no, user_id, address_id, total_amount, status, pay_time, create_timestatus 用 int 标记状态order_no 唯一order_itemid, order_id, dish_id, dish_name, price, quantity冗余菜品名和价格避免菜品改价后历史订单错乱evaluationid, order_id, user_id, content, rating订单完成后的评价可空建立orders和order_item的主外键关系但业务查询不强制走外键约束因为高并发插入时外键会影响性能。order_no建议用“日期 随机数 用户尾号”生成别直接用自增主键返回给用户。如果用过自增主键回头会发现订单号在支付回调里太短且容易被遍历猜测。3.2 MyBatis 联查菜单列表和购物车明细菜品列表要展示分类名所以 dish 表需要关联 category。在DishMapper.xml里写一个selectListByCategoryselect idselectListByCategory resultTypecom.campus.order.entity.DishVO SELECT d.id, d.name, d.price, d.image, c.name AS categoryName FROM dish d LEFT JOIN category c ON d.category_id c.id WHERE d.status 1 AND c.status 1 ORDER BY c.sort DESC, d.id ASC /select这里用DishVO而不是 Dish 实体接收结果是 MyBatis 的一对多场景常见做法。注意LEFT JOIN后如果分类表被删categoryName会为 null小程序端做if (item.categoryName)判断即可别直接取下标。ORDER BY c.sort DESC表示分类排序值越大越靠前这个习惯可以沿用。购物车明细需要按用户查同时需要知道菜品当前是否下架。常见做法是 controller 层先查购物车表再循环查 dish 表但这样会 N1 次查询。更优的做法是写一个带foreach的selectBatchByIdsselect idselectBatchByIds resultTypecom.campus.order.entity.Dish SELECT id, name, price, stock, status FROM dish WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /selectforeach的collection必须和 Mapper 接口里的参数名一致。如果你用了Param(ids) ListInteger idsXML 里就写ids如果没加注解MyBatis 只会按param1取值新手很容易在这里拿不到参数。批量查出来后在 Java 内存里用 Map 组装购物车列表可以减少数据库往返。这段selectBatchByIds的逻辑是把购物车里的菜品 id 收集成一个集合一次 SQL 查出所有菜品然后用MapInteger, Dish回填。参数ids来自前端购物车的菜品 id 数组受foreach保护不会产生 SQL 注入。3.3 订单状态机与提交订单的事务边界订单状态我固定用 0 到 4 的整数0 待支付1 已支付商家待接单2 制作中3 配送中4 已完成-1 已取消。不建议用字符串枚举因为微信支付回调只认 int小程序端 switch 也很方便。如果后面要加“退款中”状态可以在 4 之前插入 5只要保证状态单向递增即可。提交订单的 Service 代码包含三个必须在一个事务里的步骤快照购物车、扣减库存、写订单主表和明细表Transactional(rollbackFor Exception.class) public OrderSubmitResult submitOrder(OrderSubmitDTO dto) { // 1. 校验用户地址和购物车 ListCart cartList cartMapper.selectByUserId(dto.getUserId()); if (cartList null || cartList.isEmpty()) { throw new BizException(购物车不能为空); } // 2. 创建订单主表状态为待支付 Orders order new Orders(); order.setOrderNo(generateOrderNo(dto.getUserId())); order.setUserId(dto.getUserId()); order.setStatus(0); order.setTotalAmount(calcTotal(cartList)); orderMapper.insert(order); // 3. 批量扣减库存使用乐观锁 for (Cart cart : cartList) { int rows dishMapper.deductStock(cart.getDishId(), cart.getQuantity()); if (rows 0) { throw new BizException(菜品库存不足: cart.getDishName()); } } return new OrderSubmitResult(order.getId(), order.getOrderNo()); }Transactional(rollbackFor Exception.class)是必须写的因为 Spring 默认只在 RuntimeException 时回滚BizException如果继承的是 Exception不设置 rollbackFor 就会提交半截订单。deductStock用的是UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}这比先查再更新安全能在并发下防止超卖。这里还有个隐藏边界购物车表和小程序端的本地缓存不一致。我建议以服务端购物车表为准前端每次进入购物车页面都调一次GET /api/cart/list做同步而不是完全信任storage里的数据。4. 小程序端对接登录、动态标题、购物车与下单后端接口有了小程序端最关键的是登录态打通。微信小程序登录获取手机号、动态设置标题、顶部导航栏高度这些细节决定真机体验。这章会跟着真实用户的点餐路径走先进来登录再选菜、加购物车最后提交订单。4.1 微信小程序登录获取手机号wx.login 换 openid小程序端不能直接用 openid 做用户标识必须先通过wx.login拿 code再把 code 发给后端换取 session 信息。这里有两个截然不同的动作要分清wx.login不经过用户授权弹出的是微信的“同意授权”框而手机号获取必须用button open-typegetPhoneNumber触发不能直接在onLoad里调。推荐的前端流程进入小程序先wx.login拿 code调用后端/api/auth/login后端用 code 换 openid首次登录自动创建 user 记录返回自定义 token。手机号则在用户点击“一键登录”按钮时再鉴权Page({ onLoad() { wx.login({ success: (res) { wx.request({ url: https://你的域名/api/auth/login, method: POST, data: { code: res.code }, success: (resp) { const token resp.data.data.token; wx.setStorageSync(token, token); } }); } }); }, getPhoneNumber(e) { // 该回调来自 open-typegetPhoneNumber 按钮 if (e.detail.errMsg ! getPhoneNumber:ok) { wx.showToast({ title: 需要授权手机号才能下单, icon: none }); return; } wx.request({ url: https://你的域名/api/auth/phone, method: POST, data: { code: e.detail.code }, // 动态令牌有效期5分钟 header: { Authorization: wx.getStorageSync(token) }, success: (resp) { // 后端用 code 换取手机号成功后再把手机号绑定到当前用户 wx.setStorageSync(phone, resp.data.data.phone); } }); } });参数说明e.detail.code是微信开放数据平台返回的动态令牌不是wx.login的 code有效期 5 分钟后端需要用这个 code 调用微信接口换取手机号。很多教程直接把detail.userInfo里的手机号拿来做验证这是不对的——getPhoneNumber按钮的detail对象里没有直接手机号必须后端二次换取。关于“微信小程序登录获取手机号”的认证问题个人主体的小程序无法使用getPhoneNumber这个开放能力需要企业或个体工商户主体且需完成微信认证。如果你的演示账号没有认证可以在开发版先走 mock 数据不做真实手机号校验。这里也能看出为什么不建议在毕设里死磕手机号获取自填手机号配合 openid 登录演示效果差别不大。4.2 首页分类菜单与动态设置标题主页一般用 scroll-view 做分类左侧分类、右侧菜品。请求分类接口时我会把wx.request封装成 Promise避免回调地狱。动态设置标题适用于商家店铺首页比如每个店想展示“校园食堂·一楼”在 onLoad 里调用wx.setNavigationBarTitlewx.request({ url: https://你的域名/api/store/profile, success: (res) { const shopName res.data.data.shopName || 校园食堂; wx.setNavigationBarTitle({ title: shopName }); } });注意wx.setNavigationBarTitle只能改变当前页面顶部的标题文字不能改导航栏背景色。背景色需要在页面的json配置文件里写navigationBarBackgroundColor: #ff6b00。另外“微信小程序顶部导航栏高度”是个老坑iPhone X 系列和普通屏不一样如果你要在导航栏下方做自定义吸顶不能用固定 64px得用wx.getMenuButtonBoundingClientRect()拿到胶囊位置再计算导航栏高度const menuRect wx.getMenuButtonBoundingClientRect(); const navBarHeight (menuRect.top - 0) * 2 menuRect.height;这个公式在微信开发者工具里可能和真机有偏差建议以真机预览为准。如果不做自定义导航栏小程序默认的 navigationStyle 即可没必要自己造轮子。真要自定义记得在 iPhone 刘海屏上做动态安全区适配否则吸顶按钮会被状态栏遮住。4.3 购物车本地缓存与提交订单的参数校验购物车列表在校园订餐场景里我建议用本地缓存wx.setStorageSync(cart, cartItems)进入页面时先读缓存再请求服务端。但提交订单时不能把整个cartItems直接发给后端否则恶意用户能改金额。正确做法是前端只传[ { dishId: 1, quantity: 2 } ]由后端重新查库计算金额。提交订单时必须把token放在请求头里而不是 URL 参数。后端AuthInterceptor拦截/api/order/**从Authorization头里解析 token。这种设计比 cookie 更适合小程序因为小程序不会自动带 cookie。下面是提交订单的小程序端代码submitOrder() { if (!wx.getStorageSync(token)) { wx.showToast({ title: 请先登录, icon: none }); return; } const items this.data.cartItems.map(item ({ dishId: item.dishId, quantity: item.quantity })); wx.request({ url: https://你的域名/api/order/submit, method: POST, data: { addressId: this.data.addressId, items: items }, header: { Authorization: wx.getStorageSync(token) }, success: (res) { if (res.data.code 200) { // 下单成功后清空购物车本地缓存 wx.removeStorageSync(cart); wx.navigateTo({ url: /pages/order/detail?id res.data.data.orderId }); } else { wx.showToast({ title: res.data.message, icon: none }); } } }); }这段逻辑里items只保留 dishId 和 quantity防止别人篡改价格。后端在RequestBody接收OrderSubmitDTO时要把金额相关的字段标记为忽略或者干脆不接收。这样即便前端被改后端也会按数据库里的菜品价格重新计算。如果后续要接微信支付支付回调里还会再用order_no做一次金额比对越早把服务和客户端解耦后面越好处理。5. 部署联调避坑接口连不上、订单重复提交、真机白屏SSM 项目最耗时间的阶段不是写代码而是本地好好的、放上服务器就各种问题。我按频率整理了 5 个值得记下的排错记录每个记录都会给出现象、根因和解决办法可以直接复制到你的排查清单里。5.1 前端配置的接口域名与后端端口对不上现象微信开发者工具里请求http://localhost:8080/api/...能通但手机预览就白屏或一直转圈。原因微信小程序真机访问局域网 IP 需要后端接口监听在0.0.0.0且小程序后台必须配置合法域名。开发工具可以勾选“不校验合法域名”真机不行。解决后端在 SpringMVC 里别写死localhost小程序请求不要求浏览器同源但需要把http://192.168.x.x:8080加到微信公众平台的 request 合法域名里。若只是本地调试在开发者工具右上角“详情-本地设置”里勾选“不校验合法域名、web-view…”并确保手机和电脑在同一局域网。5.2 微信小程序登录获取手机号在个人主体下无法使用现象button open-typegetPhoneNumber 点击后回调errMsg: getPhoneNumber:fail no permission。原因个人主体小程序没有该开放接口权限也没有完成微信认证。很多毕设项目里跑不起来并不是代码问题而是账号资质问题。解决有两个绕法。一是改用免费测试号或让老师提供已认证主体账号二是在登录流程里不强制手机号用 openid 用户自填手机号替代。自填手机号方案在演示场景完全够用还能避开认证费用。我自己做演示时更倾向后者开发效率高。5.3 订单重复提交与库存超卖现象用户连点两次下单生成两条订单或者后台菜品库存变成负数。原因前端没有做按钮 loading后端也没有幂等校验库存扣减用了select stock then update stockstock-1并发下超卖。解决前端在点击“去支付”时setData({ loading: true })并禁用按钮后端在orders表给order_no加唯一索引提交订单前先select 1 from orders where user_id? and status0 limit 1发现待支付订单则返回同一订单而不是新建。库存扣减用乐观锁 SQLupdate dish set stock stock - #{quantity} where id #{id} and stock #{quantity}受影响行数为 0 就抛异常。注意锁的范围要覆盖整个订单别只锁住某一个菜品。5.4 MyBatis 时间字段差 8 小时现象订单创建时间在小程序端显示比北京时间慢 8 小时或者数据库存的是CST而 Java 取出来是 UTC。原因MySQL 连接串里的serverTimezone没设成Asia/Shanghai导致驱动使用默认时区。解决在jdbc.properties里把 URL 改为jdbc.urljdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse同时检查 MySQL 全局时区show variables like %time_zone%如果值不是08:00可以执行set global time_zone 08:00。代码里不要用new Date()的单参构造改用java.time.LocalDateTimeMyBatis 3.5 对 JSR310 支持良好。如果数据库里已经有存量数据记得在查询 SQL 里用DATE_FORMAT确保展示格式统一。5.5 用抓包工具对比小程序端与后端收到的参数现象后端报参数缺失但前端明明传了或者接口返回“非法请求”。原因前后端对参数名或编码不一致也可能是Content-Type不对。小程序wx.request的header默认是application/json而 SSM 的RequestBody接收时要求 JSON 字符串如果后端方法参数写了RequestParam String name就永远取不到。解决用代理工具抓包看实际请求体。常见做法是让小程序开发工具把流量打到本地 HTTP 代理端口上用抓包工具截获观察POST /api/order/submit的 body 是 JSON 还是 form-urlencoded。如果是 JSON后端 Mapper 方法参数就必须加RequestBody如果前端传的是数组要确认 JSON 序列化后的数组是否被转成了items1items2这会导致后端收不到 list。抓包不是玄学它把“我以为传了”变成“实际上传了什么”是排查联调问题最快的路径。以上 5 个坑基本覆盖了 SSM 校园订餐小程序的典型故障。每一步都能在日志里找到蛛丝马迹关键是先定位到层——是前端、后端还是数据库。6. 从“能跑”到“能扛”压测、清理与进阶扩展6.1 用 JMeter 对订单提交接口做一次压测接口通了以后我会先用 JMeter 对POST /api/order/submit做个 50 线程 × 20 循环的压力测试。线程组里加一个 HTTP 请求填Authorization头body 用固定地址 id 和两个菜品 id 的 JSON。运行后看聚合报告里的错误率和平均响应时间。SSM MySQL 单机环境订单提交接口 QPS 能到 150 以上且错误率为 0就足够支撑校园午餐高峰的分散下单。如果出现Connection is not available先看 Druid 连接池的maxActive是否小于线程数把initialSize调到 5、maxActive调到 50再跑一次。连接池参数调优比换服务器更实在。6.2 交付前的清理统一响应体、登录态与动态标题压测通过后我建议做三处清理。第一所有 Controller 方法统一返回ResultT别让一个返回 Map、另一个直接返回实体小程序端解析逻辑才能统一。第二检查Transactional(rollbackFor Exception.class)是否覆盖了订单、购物车、库存三个操作漏一个就是半截订单。第三如果你打算以后用 uniapp 微信小程序打包多端登录接口不要依赖小程序独有的code2Session之外的字段把 token 刷新逻辑独立出来。动态标题的进阶玩法是在 onShow 里根据当前时段请求一次人流量统计再把结果写到wx.setNavigationBarTitle。我一般会在服务端做 5 分钟缓存避免每进一次页面就打一次接口。记得每次交付前把数据库导出一份init.sql并清除配置里的明文密码。这套流程走完项目才算真正能交付。希望帮到你。本文还有配套的精品资源点击获取