简介基于微信小程序点餐系统的SSM后端源码案例面向毕业设计、期末作业及初学全栈的开发者提供一套结构完整、可运行的参考实现帮助理解Spring、SpringMVC、MyBatis三大框架的整合应用以及小程序端与后端的数据交互方式。包内共749个文件包含118个Java后端源码、109个Vue前端页面、23个wxml与24个wxss小程序页面另有66个js脚本、32个json配置、启动/构建bat脚本、SQL数据库脚本和详细系统设计文档整体约34.74MB目录结构清晰便于按模块阅读。已有147人浏览学习适合作为课程设计或毕业项目参考。通过该案例可掌握用户注册登录、菜品浏览、购物车、在线下单、订单管理等完整业务逻辑同时了解Spring依赖注入与事务管理、SpringMVC请求分发、MyBatis持久层映射等核心配置以及前后端接口设计、数据库表设计和微信小程序页面布局方法为独立开发类似点餐系统打下扎实基础。1. 微信小程序点餐系统与 SSM 后端的整体设计先从实际痛点切入服务员手工开台、后厨凭纸质单催菜、高峰期重复点击下单造成库存超卖。这个标题里的“基于微信小程序点餐系统的设计ssm后端”本质是用一套经典 Spring SpringMVC MyBatis 接口工程把菜单、购物车、订单、库存扣减收口到数据库事务里小程序端只负责交互。对正在按后端开发学习路线进阶的人来说价值在于购物车与订单状态流转、并发扣库存、登录鉴权都能对照源码逐行读。标题里的“带文档说明”通常包含需求说明、数据库设计说明和部署文档拿到压缩包后可以先把文档和 sql 目录对齐再渐进式看后端接口。2. 点餐数据模型与 MyBatis 映射从建表 SQL 到动态更新我接到这个任务会先画 ER 图不急着写接口。点餐系统的核心业务对象是用户、菜品、购物车和订单。如果表设计没定后面所有事务和并发控制都是无根之木。下面这套表结构是我在同类小程序点餐案例里最常用的一版字段不追求多但每个字段都能回答一次联调中的问题。2.1 用户、菜品、购物车与订单的核心表结构表名职责关键字段说明user小程序用户openid, nickname, phoneopenid 唯一登录时按它 upsertcategory菜品分类name, sort决定首页左侧分类栏顺序dish菜品category_id, name, price, stock, status, salesprice 用 decimal(10,2)cart购物车user_id, dish_id, quantity, checked相同菜品用唯一索引orders订单主表order_no, user_id, total_amount, status, remarkstatus 0 待支付1 已支付2 制作中3 已完成-1 已取消order_item订单明细order_id, dish_id, name, price, quantity冗余菜品名称和价格防止菜品改价后历史订单失真购物车表需要单独关注加同款菜品时不能插入两条记录CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, checked TINYINT NOT NULL DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_cart_user_dish (user_id, dish_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;uk_cart_user_dish保证同一个用户加同一个菜品时走ON DUPLICATE KEY UPDATE而不是产生两条脏数据。很多点餐案例的购物车数量越加越多就是因为漏了这层唯一约束。订单主表也一样order_no 必须唯一这是防重复提交的最底层防线CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_create (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_user_create是给“我的订单”列表用的。小程序端 if 判断比较多订单列表接口一旦 query 条件没有索引数据量上去后第一个慢 SQL 就会出现在这里。2.2 MyBatis 结果映射一对多查询不写循环赋值订单详情页总要同时展示订单和明细。如果先查orders再循环查order_item会产生 N1 查询。SSM 项目里的常见做法是用resultMap的collection一次性查出订单和明细resultMap idOrderWithItemMap typecom.example.point.entity.Order id propertyid columnid/ result propertyorderNo columnorder_no/ result propertytotalAmount columntotal_amount/ result propertystatus columnstatus/ collection propertyitems ofTypecom.example.point.entity.OrderItem id propertyid columnitem_id/ result propertydishName columndish_name/ result propertyprice columnitem_price/ result propertyquantity columnquantity/ /collection /resultMap select idselectOrderWithItems resultMapOrderWithItemMap SELECT o.id, o.order_no, o.total_amount, o.status, oi.id AS item_id, oi.dish_name, oi.price AS item_price, oi.quantity FROM orders o LEFT JOIN order_item oi ON o.id oi.order_id WHERE o.id #{orderId} /select这里两个表都有id和price列SQL 里必须用AS item_id、AS item_price起别名resultMap中的column才会对应到正确列。如果你读过 mybatis 源码会知道collection的装配依赖ResultSet按行处理加上id元素后同一订单的多行明细才会合并进同一个Order对象。2.3 扣库存的动态 SQL把判断写进 UPDATE点餐系统最核心的并发场景发生在提交订单时。不要把“查库存再改库存”拆成两条 SQL因为两条 SQL 之间有间隙两个请求可能同时看到库存为 1最后都扣成功。SSM 里最稳妥的写法是把库存条件并进 UPDATE 的 WHEREupdate iddeductStock UPDATE dish SET stock stock - #{quantity} WHERE id #{dishId} AND stock gt; #{quantity} AND status 1 /update对应的 Mapper 接口方法返回影响行数int deductStock(Param(dishId) Long dishId, Param(quantity) Integer quantity);调用后判断返回值如果affectedRows 0直接抛业务异常“菜品库存不足”。stock gt; #{quantity}一定要写gt;是 XML 对的转义漏掉会直接报 XML 解析错误。这种方式不需要锁表也不依赖 Redis适合以 MySQL 为唯一存储的小程序点餐系统。3. SSM 后端接口的事务边界与并发扣库存实现SSM 的经典分层是 Controller-Service-Mapper。点餐系统里最容易写坏的是 Service 层有人把扣库存写在 Controller有人把查询购物车和创建订单拆成两个事务。下面按最稳妥的分层来拆。3.1 RESTful 接口设计与统一返回结构先把接口定下来页面和文档才能同步推进。小程序点餐系统的最小接口集合如下方法路径入参返回POST/api/user/login{ code }tokenGET/api/category/list无分类列表GET/api/dish/listcategoryId, page, size菜品分页POST/api/cart/add{ dishId, quantity }购物车总数量POST/api/order/submit{ remark, cartItemIds }订单号GET/api/order/liststatus订单列表统一返回结构体避免每个接口的 JSON 层次都不一样public class ResultT { private Integer code; // 0 成功非 0 失败 private String msg; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.code 0; r.msg ok; r.data data; return r; } }Controller 层用RestControllerSpringMVC 会自动把返回值序列化成 JSONRestController RequestMapping(/api/order) public class OrderController { private final OrderService orderService; PostMapping(/submit) public ResultString submit(RequestBody OrderSubmitRequest req) { return Result.success(orderService.submit(req)); } }注意RestController是Controller和ResponseBody的组合注解不需要每个方法再加ResponseBody。从 SSM 迁移到前后端分离项目实战时这是最常见的差异点。3.2 下单扣库存的事务边界与防重处理Service 实现要把“校验购物车、生成订单、写明细、扣库存、清空购物车”放到同一个事务方法里。先让cartMapper.selectCheckedByUser联表查出菜品价格和名称返回CartItem视图对象Transactional(rollbackFor Exception.class) public String submit(OrderSubmitRequest req) { Long userId UserContext.get(); ListCartItem selectedItems cartMapper.selectCheckedByUser(userId); if (selectedItems null || selectedItems.isEmpty()) { throw new BizException(请先选择要下单的菜品); } BigDecimal totalAmount BigDecimal.ZERO; for (CartItem item : selectedItems) { totalAmount totalAmount.add( item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(0); orderMapper.insert(order); for (CartItem item : selectedItems) { OrderItem detail new OrderItem(); detail.setOrderId(order.getId()); detail.setDishId(item.getDishId()); detail.setDishName(item.getDishName()); detail.setPrice(item.getPrice()); detail.setQuantity(item.getQuantity()); orderItemMapper.insert(detail); int affected dishMapper.deductStock(item.getDishId(), item.getQuantity()); if (affected 0) { throw new BizException([ item.getDishName() ] 库存不足); } } cartMapper.clearSelected(userId); return orderNo; }Transactional(rollbackFor Exception.class)是关键。Spring 默认只对RuntimeException回滚如果BizException继承的是Exception不加rollbackFor就会出现在订单明细已经插入、库存扣减失败时事务仍然提交的严重问题。generateOrderNo()用时间戳加用户 ID 加随机数生成然后依赖orders表的uk_order_no做幂等兜底。前端按钮置灰只解决“手快”真正拦截重复请求的是数据库唯一索引。这个案例没引入 Redis 时用数据库唯一键成本最低。3.3 登录拦截与 UserContext登录态不能靠浏览器 Cookie。小程序每次请求带上Authorization请求头后端用拦截器解析 tokenpublic class LoginInterceptor implements HandlerInterceptor { private TokenStore tokenStore; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); Long userId tokenStore.get(token); if (userId null) { response.setStatus(401); return false; } UserContext.set(userId); return true; } }UserContext用 ThreadLocal 保存当前线程的用户 IDService 层通过UserContext.get()获取避免每个方法都显式传 userId。拦截器只拦截/api/order/**和/api/cart/**登录接口和菜品列表保持匿名访问。开发时如果使用 HBuilderX 连接真机预览局域网请求偶尔会遇到跨域问题。SSM 项目的常见做法是加一个 CorsFilter 放行指定的 header但小程序真机没有浏览器同源策略真正要做的是在小程序后台配置 request 合法域名并且必须是 HTTPS。4. 微信小程序登录与点餐请求接入 SSM 接口的完整链路小程序端不能直接调微信code2Session接口code 要发给自己的后端由后端换 openid。下面这条链路是微信小程序接入 SSM 后端最常见的实现。4.1 用 wx.login 换 token而不是传微信号后端提供一个/api/user/login接口入参是wx.login返回的 codePostMapping(/api/user/login) public ResultString login(RequestBody LoginRequest req) { String openid wechatService.openidByCode(req.getCode()); User user userMapper.selectByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); userMapper.insert(user); } String token UUID.randomUUID().toString().replace(-, ); tokenStore.put(token, user.getId()); return Result.success(token); }小程序端在App.onLaunch里完成登录App({ onLaunch() { wx.login({ success: (loginRes) { wx.request({ url: http://localhost:8080/api/user/login, method: POST, data: { code: loginRes.code }, success: (res) { if (res.data.code 0) { wx.setStorageSync(token, res.data.data); } } }); } }); } });wx.login的 code 有效期为 5 分钟且只能使用一次后端不要缓存 code。登录后把 token 存到wx.setStorageSync后续请求统一放到 header 的Authorization字段。如果小程序工程是用 uniapp 微信小程序开发的逻辑完全一致只需把wx.login换成uni.login、wx.request换成uni.request。SSM 后端不需要感知前端框架差异。下表是前端场景与后端接口的对应关系小程序场景前端调用SSM 后端接口进入页面自动登录wx.login wx.requestPOST /api/user/login加载分类和菜品wx.requestGET /api/category/list、GET /api/dish/list加入购物车wx.requestPOST /api/cart/add提交订单wx.requestPOST /api/order/submit4.2 请求封装与点餐页面数据流每次写wx.request会重复处理错误提示我一般会封装一个小工具const request (url, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: ${getApp().globalData.baseUrl}${url}, method, data, header: { Authorization: wx.getStorageSync(token) }, success(res) { if (res.data.code 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: none }); reject(res.data); } }, fail: reject }); }); };点餐首页按分类加载菜品Page({ data: { dishes: [], currentCategoryId: 0, loading: false }, switchCategory(categoryId) { this.setData({ currentCategoryId: categoryId, loading: true }); request(/api/dish/list?categoryId${categoryId}) .then((dishes) this.setData({ dishes })) .finally(() this.setData({ loading: false })); } });loading字段是用来配合wx.showLoading的。很多人在修改刚进入的加载页面时会漏掉它导致分类切换时旧数据还没清掉页面出现菜单闪烁。如果点餐页用了自定义导航栏在app.json设置navigationStyle: custom后还要在onLoad里用wx.getMenuButtonBoundingClientRect测量胶囊按钮位置否则页面内容会顶到状态栏。这个细节在真机上特别明显。4.3 购物车和订单提交的防重复提交加入购物车addToCart(e) { const { dish } e.currentTarget.dataset; request(/api/cart/add, POST, { dishId: dish.id, quantity: 1 }) .then((totalCount) { this.setData({ badge: totalCount }); }); }下单按钮submitOrder() { if (this.data.submitting) return; this.setData({ submitting: true }); request(/api/order/submit, POST, { remark: this.data.remark }) .then((orderNo) { wx.showToast({ title: 下单成功 }); wx.navigateTo({ url: /pages/order/detail?orderNo${orderNo} }); }) .finally(() this.setData({ submitting: false })); }前端submitting置灰只解决手快问题后端事务和唯一索引才是防重复提交的最终防线。购物车角标要以后端返回的总数为准不要用this.data.badge 1在前端累加否则多端登录时数量会不一致。5. 源码案例的工程结构、文档说明与本地启动步骤5.1 拿到 zip 后先按四个目录拆解标题里明确写了“带文档说明”这类压缩包通常会包含四部分目录/文件作用启动前要做什么docs/需求说明、接口文档、数据库设计先看接口文档和状态定义sql/建库建表脚本导入 MySQLssm-backend/SSM 后端工程改数据库连接部署 Tomcatminiapp/微信小程序前端改 baseUrl导入开发者工具阅读顺序一般按 docs → sql → ssm-backend → miniapp。“带文档说明”不等于只丢一个 README而是要有文档和代码的对应关系接口文档里每个 URL能在 Controller 里找到数据库设计文档里的每个表能在 sql 目录下找到。5.2 数据库导入与后端配置在 MySQL 里执行建库脚本mysql -uroot -p sql/point_meal.sql然后修改src/main/resources/jdbc.propertiesjdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/point_meal?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.password123456serverTimezoneAsia/Shanghai是为了让 Java 侧日期和 MySQL DATETIME 落在同一时区useSSLfalse避免本地 MySQL 没配证书时出现 SSL 警告。java后端和数据库连接的问题八成集中在这一行配置上。5.3 启动后端与小程序端传统 SSM 项目用 Maven 打 war 包mvn clean package -DskipTests把 war 包拷到 Tomcat 的webapps/目录启动 Tomcat。如果你的 pom 依赖了spring-boot-maven-plugin也可以直接mvn spring-boot:run两种方式不要混用否则会出现类冲突。启动日志里看到SpringMVC 初始化完成或者Tomcat started on port(s): 8080后端就起来了。小程序端用微信开发者工具导入miniapp/目录在app.js或config.js中修改globalData: { baseUrl: http://localhost:8080 }本地开发时在开发者工具右上角“详情-本地设置”勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。真机预览时要把 baseUrl 换成电脑的局域网 IP。提示开发者工具的“不校验合法域名”选项只对本地开发有效发布前必须在小程序后台配置 HTTPS 的 request 合法域名否则正式版会请求失败。5.4 启动失败排查现象排查点数据库连接失败检查 MySQL 是否启动、密码是否正确、jdbc.url 是否拼错端口被占用用lsof -i:8080看占用进程小程序请求直接 fail看 Network 面板确认 baseUrl 是否被开发者工具改成了 https后端接口 404检查拦截器是否拦截了静态资源SpringMVC 是否配置了 mvc:resources一个需要特别提醒的坑小程序请求不像浏览器会自动携带 CookieSSM 后端如果依赖HttpSession保存登录态会发现第一次请求后 session 就丢了。这类小程序点餐案例里应该用 token 存内存或数据库而不是依赖request.getSession()。6. 联调排错从小程序报错逆向定位 SSM 后端问题这里分享一个我常用的联调方法叫“错误关键字分层定位”。小程序控制台出现request:fail时先打开开发者工具的 Network 面板看请求到底有没有发出去。如果请求根本没发出问题在 baseUrl 或开发者工具的域名校验如果请求已发出但状态码是 401问题在登录态如果状态码是 500问题在后端异常。request:fail本身并不代表后端挂了只代表wx.request这一层没有拿到成功响应。第二个技巧是在 SSM 后端加一个记录请求耗时的过滤器用来快速定位慢接口Component public class AccessLogFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { long start System.currentTimeMillis(); chain.doFilter(request, response); HttpServletRequest httpReq (HttpServletRequest) request; long cost System.currentTimeMillis() - start; System.out.printf(%s %s cost%dms%n, httpReq.getMethod(), httpReq.getRequestURI(), cost); } }SpringMVC 的日志只能看到请求进入哪个 Controller这个过滤器还能看到请求真实耗时。如果POST /api/order/submit耗时超过 300ms优先查慢 SQL。用EXPLAIN看orders表是否走了idx_user_create索引看order_item的order_id是否有索引点餐系统的写入瓶颈通常不在 SQL 本身而在缺失索引导致的锁范围扩大。真机联调阶段如果怀疑小程序端提交的参数不对用开发者工具的“真机调试”配合后端日志最直接。不方便看手机时也可以用 Charles 抓包电脑端微信小程序但优先看开发者工具自己的 Network因为wx.request的请求头、响应体更直观。点餐系统联调时最典型的问题是后端返回Result.success后小程序却进不了then。原因往往是后端在返回时又包了一层 JSON导致res.data.code变成了undefined。遇到这种情况直接在success回调里console.log(res.data)对比后端接口文档里的返回示例确认是包了两层还是字段名大小写不一致。本文还有配套的精品资源点击获取