简介:这份压缩包是面向Java初、中级学习者和毕业设计开发的校园订餐微信小程序完整项目,基于SSM框架与MySQL数据库实现,覆盖管理员、用户、商家三大角色,包含餐厅信息管理、美食分类与展示、购物车、订单管理、用户充值、收藏等典型业务模块。包内共有1414个文件,以Java源码、Vue后台页面、微信小程序wxml/wxss/js组件为主,png/svg等图片资源用于界面图标与展示,xml/properties作为配置支撑,并附带SQL数据库脚本和环境安装、启动打包脚本,便于本地部署和二次开发。整包约24.77MB,目录按前端、后台、数据库等分层组织,已有107人浏览学习。借助这套源码可快速理解SSM与小程序前后端的交互流程,梳理多角色权限控制和订单状态设计思路,对完成课程设计、毕业设计或学习真实项目结构具有直接参考价值。
1. 校园订餐为什么还要选 SSM 这套“老组合”
在毕设和中小型项目里,SSM 并没有被 Spring Boot 完全取代。原因很直接:Spring MVC 把路由和参数绑定放在注解里,MyBatis 的 SQL 可控性强,对 MySQL 的复杂查询(比如餐厅—美食—订单关联)比 JPA 直观。这个项目把微信小程序当作客户端,管理后台用 Vue,后端就是标准 SSM,结构上把用户、商家、管理员三种身份拆开,正好对应校园订餐里“谁来点、谁来接、谁来管”的边界。适合想从零看 Java Web 全栈的人,也适合毕业设计要展示分层设计、并需要快速部署的场景。很多人拿到源码第一件事是跑通,但真正要交的是“能不能讲清楚订单和余额为什么这么设计”。以下内容全部基于这套常见工程结构展开。
2. SSM 后端拆分:从 MySQL 表设计到 Mapper 层的边界
2.1 数据表:餐厅、美食、订单和余额的状态机
先看核心表结构。标准校园订餐会把用户、商家、餐厅、美食、美食类型、购物车、订单、订单详情、充值记录、收藏建为 10 张表。订单表是最复杂的,它的状态机直接影响前端按钮展示和后端并发处理方式。
| 状态值 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 待支付 | 用户取消订单,或订单超时释放库存 |
| 1 | 已支付 | 商家接单,用户可申请退款 |
| 2 | 配送中 | 商家标记出餐,用户等待 |
| 3 | 已完成 | 用户确认收货,订单归档 |
| 4 | 已取消 | 系统关闭,无法恢复 |
用户表user里有openid唯一索引、balance余额和role区分用户/商家。美食表food要冗余冗余餐厅名称或restaurant_id外键,方便列表页联查。这里的关键是order_no不能依赖自增 ID,因为支付回调要拿它做幂等;前端生成订单号时如果只靠System.currentTimeMillis(),同一毫秒并发会碰撞,建议用时间戳加用户 ID 加随机数。
在UserMapper.xml中,更新余额用一行 SQL:
UPDATE user SET balance = balance + #{amount} WHERE user_id = #{userId}为什么不用先在 Java 里查余额再加再写回?因为两个并发订单同时读余额,会出现丢失更新。这条 SQL 在数据库行锁级别保证加减是原子的。充值表和用户表不要用“先查后更”,直接用上述 SQL,再插入一条recharge流水即可。
2.2 Controller 只做参数转发,业务规则下沉
SSM 项目的经典错误是业务逻辑写在 Controller 里。下面这段OrderController示例只做参数校验和路由:
@Controller @RequestMapping("/api/order") public class OrderController { @Autowired private OrderService orderService; @PostMapping("/create") @ResponseBody public Result create(@RequestBody OrderCreateParam param, @RequestAttribute("userId") Integer userId) { if (param.getFoodList() == null || param.getFoodList().isEmpty()) { return Result.error("购物车不能为空"); } return orderService.createOrder(userId, param); } }注意@RequestAttribute("userId")来自拦截器,拦截器在HandlerInterceptor里解析 token 后写入 request attribute。这样做的好处是 Controller 层不需要重复解析 token,后续的商家接口、管理端接口也复用同一套身份处理。
订单创建的核心事务方法在 Service 层:
@Transactional(rollbackFor = Exception.class) public Result createOrder(Integer userId, OrderCreateParam param) { BigDecimal total = calculateTotal(param.getFoodList()); if (total.compareTo(BigDecimal.ZERO) <= 0) { return Result.error("订单金额异常"); } User user = userMapper.selectById(userId); if (user.getBalance().compareTo(total) < 0) { return Result.error("余额不足"); } String orderNo = generateOrderNo(); // 锁定库存:update food set stock=stock-#{num} where food_id=#{id} and stock>=#{num} // 返回影响行数为 0 时说明库存不足 // 扣除余额:update user set balance=balance-#{total} where user_id=#{userId} orderMapper.insert(order); orderItemMapper.insertBatch(param.getFoodList()); return Result.success(orderNo); }@Transactional要指定rollbackFor = Exception.class,否则RuntimeException之外的自定义异常不会回滚。库存扣减要放在创建订单之前,且 SQL 自带stock >= #{num}条件,避免超卖。
2.3 Mapper 层联表查询与分页
SSM 项目里使用 MyBatis 分页插件 PageHelper 很常见。下面是订单列表联表查询的 Mapper XML:
<select id="selectOrderPageByUser" resultType="com.example.vo.OrderVO"> SELECT o.order_no, o.total_amount, o.status, r.name AS restaurant_name, GROUP_CONCAT(f.name SEPARATOR '、') AS food_names FROM orders o LEFT JOIN restaurant r ON o.restaurant_id = r.restaurant_id LEFT JOIN order_item oi ON o.order_id = oi.order_id LEFT JOIN food f ON oi.food_id = f.food_id WHERE o.user_id = #{userId} GROUP BY o.order_id, o.order_no, o.total_amount, o.status, r.name ORDER BY o.create_time DESC </select>这里用GROUP_CONCAT把一次订单一口气查出来,避免在 Java 里再循环查 food 名称形成 N+1 查询。PageHelper 用法是在紧邻 Mapper 方法的第一行设置页码和页大小:
PageHelper.startPage(pageNum, pageSize); List<OrderVO> list = orderMapper.selectOrderPageByUser(userId); PageInfo<OrderVO> pageInfo = new PageInfo<>(list);注意PageHelper.startPage和Mapper方法之间不能插入其他 SQL 调用,否则分页会被污染。真实项目中,我一般会把分页参数封装成PageParam对象,避免每个方法都写两个形参。
3. 小程序端与 Vue 管理后台的权限协作
3.1 微信小程序登录态:wx.login 换 token
校园订餐小程序端登录几乎都走同一套:wx.login拿临时 code,传给后端换取 openid,再生成自定义登录态。小程序端代码:
wx.login({ success: async (res) => { const loginRes = await request.post('/user/login', { code: res.code }); const { token, userInfo } = loginRes.data; wx.setStorageSync('token', token); wx.setStorageSync('userInfo', userInfo); } });后端拿到 code 后调用微信接口换取 openid,再把 openid 和自增 user_id 绑定到 token。token 用 JWT 还是 UUID 都行,但注意校园项目里 token 要能快速失效,商家账户被审核拒绝后要能踢下线,所以建议把 token 存 Redis 而不是只靠 JWT 的过期时间。
后续请求统一封装:
const request = { get(url, params) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${url}`, data: params, header: { Authorization: wx.getStorageSync('token') }, success: resolve, fail: reject }); }); } };BASE_URL建议放在一个独立config.js文件里,真机调试时用局域网 IP,不要用 localhost,否则手机访问不到电脑上的 Tomcat。如果后端接口报 401,先看请求头里 Authorization 是否带上,再排查拦截器是否把/user/login列入了白名单。
3.2 Vue 管理后台:从 .bak 文件看构建工程
拿到压缩包后你会看到一堆.vue.bak文件,比如IndexMain.vue.bak、IndexAsideStatic.vue.bak、update-password.vue.bak、BreadCrumbs.vue.bak、IndexHeader.vue.bak。这是用 Vue Element Admin 之类的后台模板改的,.bak是开发者在改坏页面或升级组件前留下的备份。update-password.vue.bak通常里面对应个人中心修改密码的静态页面,IndexMain.vue.bak是主布局,IndexHeader.vue.bak是顶栏,IndexAsideStatic.vue.bak是侧边栏菜单。这些文件被保留下来,恰好可以用来对比菜单权限的写法。
管理后台在src/router/index.js里添加路由和 meta 权限:
{ path: '/merchant/restaurant', component: () => import('@/views/merchant/restaurant.vue'), meta: { roles: ['admin', 'merchant'] } }在路由守卫里判断角色,防止用户直接输入 URL 访问商家页面。注意这个判断要以后端返回的role为准,不要去解析 token 里的字段。角色权限建议做成一张表:
| 角色 | 可访问模块 |
|---|---|
| admin | 用户管理、商家审核、系统管理、订单总览 |
| merchant | 餐厅管理、美食管理、订单处理 |
| user | 首页、餐厅信息、购物车、我的订单、收藏 |
下面三个脚本是本项目的构建入口:
# 1-install.bat 安装依赖 npm install --registry=https://registry.npmmirror.com # 2-run.bat 本地开发 npm run dev # 3-build.bat 打包上线 npm run build1-install.bat里一般还要补一句npm install后的提示,检查node_modules是否生成。构建后 dist 目录由 Nginx 或 Spring Boot 静态目录托管,这里有一个常见误区:不要直接用file://打开dist/index.html,因为路由是 history 模式,跳转子页面会白屏,要交给 web 服务器处理。
3.3 商家上传美食图片的路径问题
商家在小程序端上传餐厅 Logo 和美食图片,后端用MultipartFile接收。SSM 的spring-mvc.xml要配上传大小限制:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="5242880" /> </bean>保存文件的物理路径不要写在代码里,放到properties里,同时用虚拟目录映射到对外 URL:
String realPath = uploadConfig.getDir(); String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename(); file.transferTo(new File(realPath, fileName));上传完成后把img/文件名存库,小程序端展示时拼上服务器地址。如果图片加载 404,先确认是不是浏览器能访问http://localhost:8080/img/xx.jpg,不能访问就检查虚拟目录路径,不要怀疑小程序代码。
4. 订单、购物车与充值:SSM 事务下的资金流实现
4.1 购物车勾选与一次性下单
购物车表按user_id + food_id存一份,数量可增减。小程序端结算页把选中商品的foodIdList传到后端。后端需要校验食品是否下架、是否属于同一家餐厅。一份订单只能对应一家餐厅,所以先判断购物车里食品的restaurant_id是否一致,如果不一致就提示“只能同时结算同一家餐厅的商品”。很多校园订单误操作都从这里来,接口返回提示文案要明确到“餐厅名”。
下单接口里不要直接使用前端传入的price,否则用户改请求参数就能把价格改成 0。正确做法是从数据库中根据 food_id 批量查出最新价格,与前端数量做乘法得出总价。如果平台有满减活动,还需要在 Service 层单独计算优惠金额,而不是信任前端传过来的discount字段。
4.2 余额充值与模拟支付
微信小程序个人主体的支付能力经常被限制,这就是为什么很多毕设项目把支付换成余额充值。充值的核心是生成充值单,然后跳到微信支付的模拟页面。实际实现:
public Result recharge(Integer userId, BigDecimal amount) { String rechargeNo = "RE" + System.currentTimeMillis() + ThreadLocalRandom.current().nextInt(1000); rechargeMapper.insert(new Recharge(rechargeNo, userId, amount, "PENDING")); return Result.success(rechargeNo); }小程序端拿到rechargeNo,把用户引到一个确认页,点击“去支付”后直接调用后端的payCallback模拟支付成功。payCallback里必须做幂等:
@Transactional(rollbackFor = Exception.class) public void payCallback(String rechargeNo) { Recharge r = rechargeMapper.selectByNoForUpdate(rechargeNo); if (r == null || "SUCCESS".equals(r.getStatus())) { return; } rechargeMapper.updateStatus(rechargeNo, "SUCCESS"); userMapper.updateBalance(r.getUserId(), r.getAmount()); }selectByNoForUpdate是行级锁,两个请求同时进来时只有一个能更新;SUCCESS判断保证重复回调不重复加钱。订单支付同理,orders表加唯一索引order_no,更新时用where order_no=#{orderNo} and status=0。如果你接的是真实微信支付,回调函数必须返回 XML 给微信服务器,这里不展开,但模拟支付的接口设计和真实回调路径保持一致,后面接支付服务商时改动很小。
4.3 常见事务坑:MyBatis 一级缓存与事务回滚
在同一个事务里,先selectByNo再update再selectByNo,第二次查到的是一级缓存里的老数据,造成“状态没变”的错觉。解决办法是在更新方法后面重新查询原生字段,或者更直接地根据影响行数判断。商品库存扣减的 XML:
<update id="deductStock"> UPDATE food SET stock = stock - #{num} WHERE food_id = #{foodId} AND stock >= #{num} </update>如果影响行数为 0,抛出库存不足异常,整个订单事务回滚。注意在@Transactional方法里,如果调用同类中的另一个@Transactional方法是不会生效的,要自行注入自身或拆到不同 Service。这个坑在面试时也常被问到,记下来很有用。
5. 部署时被忽略的细节:数据库初始化、日志与真机联调
SSM 项目从压缩包解压到浏览器能访问,中间有三个隐藏点。一是数据库初始化,压缩包经常不带init.sql,但会留.sql文件或db.properties配置。你可以在 MySQL 里先建库,再用source命令导入。Tomcat 部署时如果中文乱码,需要修改server.xml的 connector 加上URIEncoding="UTF-8"。
两个容易卡住的地方是微信小程序项目未配置合法域名,以及开发者工具提示“登录用户不是该小程序的开发者”。前者在开发阶段直接勾选“不校验合法域名”,后者要去微信公众平台成员管理里把你微信号添加为开发者。真机调试时,手机和电脑保持同一局域网,config.js里的地址不能填localhost,要填电脑的局域网 IP。
最后一个技巧是用抓包工具定位小程序请求超时。常见有 Charles 和 Fiddler,设置抓包后优先看POST /user/login和POST /order/create的返回。如果返回时间超过 3 秒,多半是数据库连接池配置太小或 SQL 没加索引。orders表的user_id和status要建联合索引,否则随着订单表增长,联表查询会越来越慢。就按这个顺序把项目从跑通到优化走一遍,答辩或上线都不会心虚。
本文还有配套的精品资源,点击获取