ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

校园智慧订餐平台毕设全解析:SpringBoot+微信小程序一体化实战

校园智慧订餐平台毕设全解析:SpringBoot+微信小程序一体化实战 每年到毕业季总有人问我Java毕设到底选什么题我的答案经常是——找一个业务闭环够完整、技术密度足够、演示效果又直观的题目。校园智慧订餐平台就是这么被我“翻牌子”的它表面是个餐饮点餐系统实际把SpringBoot后端、微信小程序前端、订单状态机、库存并发、配送消息推送全串在一条业务链上做下来几乎能覆盖Java后端开发的主流知识点。这篇文章我会把从选题分析、技术选型、数据库设计到后端接口、小程序踩坑、配送模块、答辩准备的完整思路捋一遍给正在做Java毕设或者想拿这套题目练手的朋友一个可以直接参考的路线。1. 校园智慧订餐平台到底在解决什么问题1.1 为什么是“智慧订餐”而不是“点餐系统”很多人一开始把题目写成“餐饮点餐系统”结果做着做着就变成了购物车增删改查加一张订单表。我不建议这么干因为“点餐”两个字撑不起毕设的体量。校园智慧订餐平台的“智慧”至少应该体现三件事菜品不是平铺的一张大列表而是按校园内的食堂、店铺、分类多层组织用户能快速找到自己想要的窗口。订单状态全链路可视化用户端、商家端、骑手端看到的状态各自不同但底层是同一套状态机。配送和订单状态联动而不是做完订单之后单独再写一个“显示骑手电话”的静态页面。这三点决定了题目的含金量。答辩时老师大概率会问“你和普通点餐系统有什么区别”如果只能回答“我多了个商家端”基本等于没说。真正能扛住追问的是你把用户从选菜、下单、支付、商家接单、骑手配送、确认收货这条完整链路做成了一体化系统。1.2 三类角色和一条核心业务链路这套系统的参与角色不像普通博客项目只有“用户-管理员”而是三类小程序端角色加一个后台管理端用户端学生浏览食堂和店铺、查看菜品、加购物车、下单、模拟支付、查看订单状态、确认收货、评价。商家端食堂窗口/店铺菜品管理、订单管理、接单/拒单、出餐、更新订单状态。骑手端校园配送员查看可接配送单、抢单、取餐、上报位置、送达。管理后台管理用户、店铺、菜品分类、订单统计、基础数据维护。核心链路很清晰用户在首页选店铺 - 加购菜品 - 提交订单并支付 - 商家端收到新订单 - 商家接单并出餐 - 骑手端接配送单 - 骑手取餐 - 开始配送 - 用户确认收货 - 订单完成。这条链路如果全部跑通你写的就不仅仅是一个“XX管理系统”而是一个多人协作的小型业务系统。这也是这套题最适合做毕设的原因每一个角色都有独立页面、独立接口、独立权限工作量分布均匀不会出现“前端写完了后端还没开始”的尴尬。1.3 校园场景和外卖平台相比隐藏了哪些需求校园场景有几个特殊点和真实外卖平台不完全一样高峰并发明显中午和晚上下课时间下单集中某个热门窗口的招牌菜可能在同一秒被很多学生下单。这就逼着你处理库存扣减和并发问题而不是简单“update dish set stock stock - 1”。配送距离短校园内配送基本是食堂到宿舍楼不需要复杂的地图路线规划但需要轨迹记录来体现“配送过程”。身份范围固定用户主要是在校学生商家是校内食堂/店铺天然适合用微信小程序这种轻量载体不用做App。支付资质受限真正的微信支付需要企业主体和商户号学生个人没法申请。所以毕设阶段通常做模拟支付但接口边界要提前预留好。把这些先想明白后面每一步设计都不会跑偏。2. 技术选型不是堆框架SpringBoot 微信小程序方案的取舍2.1 后端为什么选 SpringBoot而不是 SSH 或 SSM很多课程还在讲 SSMSpring SpringMVC MyBatis但企业项目和当下绝大多数教程都已经切换到 SpringBoot。我选 SpringBoot 的理由很直接内置 Tomcat不用单独配置服务器打包成 jar 就能跑。自动装配大大减少 XML 配置开发效率高毕设时间本来就紧。生态成熟集成 MyBatis-Plus、Redis、WebSocket、JWT 都有现成 starter。简历上写“熟练使用 SpringBoot”比写“熟悉 SSM 配置”有说服力得多。这里有个现实建议不要盲目追 SpringBoot 3.x 和 JDK 17。我用的是 SpringBoot 2.7 JDK 8 的组合理由不是技术落后而是网上大量教程、MyBatis-Plus 版本、各种博客示例都基于 javax 命名空间SpringBoot 3.x 改成了 jakarta很多老代码直接跑不起来。如果你非要追新要有花大量时间排查环境问题的心理准备。毕设的核心是把业务做扎实不是帮框架踩坑。2.2 小程序端用原生还是 uni-app如果题目只说了“微信小程序”我建议优先用原生微信小程序开发。原因很简单原生是微信官方生态API 最直接查文档不会被“多端框架封装的坑”挡住。毕设只需要跑在微信里不需要一码多端。微信开发者工具的调试体验足够好报错信息也直观。如果你之前已经熟悉 Vue非要用 uni-app 开发也不是不行但一定要多做一步用 HBuilderX 的“发行 - 小程序”跑一遍真实打包流程。热门帖子里经常看到“uniapp微信小程序打包后样式错乱、组件不渲染”的问题根因大多是某些 API 在 H5 端和小程序端行为不一致。毕设阶段时间和精力都有限我最终选择了原生把 uni-app 的坑留到工作里再踩。2.3 辅助技术栈不是越多越好而是每个都解决具体问题我最终使用的技术栈如下技术作用为什么选它MySQL 8主数据库免费、稳定、学校环境普遍安装MyBatis-PlusORM 框架减少大量单表 CRUD内置分页插件Redis缓存 / Token / 分布式锁缓存菜品列表、存微信登录态、抢单时防并发WebSocket订单状态实时推送用户端和骑手端页面能自动刷新状态JWT登录鉴权小程序端无状态认证比 Session 更适合接口开发Hutool工具库封装 HTTP 请求、JSON 转换调微信接口很方便这些技术每个都有明确的目的不是听别人说“Redis 很火”就硬塞进去。尤其要注意毕设项目最怕过度设计如果只是简单查询业务你不需要引入消息队列、分布式微服务那一套。把 SpringBoot 里的分层写好、事务和并发处理好比堆一堆跑不动的框架强得多。3. 数据库设计从菜品分类到配送轨迹一张表都别省3.1 全局表结构规划我设计这套库时第一件事不是建表而是把角色和业务流程画出来再反推需要哪些表。最终核心表如下表名作用关键字段user用户学生/商家/骑手统一账户openid, nickname, avatar, roleshop店铺食堂窗口name, campus, status, noticecategory菜品分类shop_id, name, sortdish菜品shop_id, category_id, name, price, stock, image, statuscart购物车user_id, dish_id, quantity, selectedorders订单主表order_no, user_id, shop_id, amount, status, address, rider_idorder_item订单明细order_id, dish_id, dish_name, dish_image, price, quantitydelivery配送单order_id, rider_id, status, pickup_time, finish_timedelivery_track配送轨迹delivery_id, lng, lat, create_time一眼看上去表不多但已经覆盖了“用户-商品-订单-配送”四个核心维度。我特别想强调的是千万别把订单明细直接存成字符串塞进订单表。如果将来要做销量统计、菜品排行、商家对账没有 order_item 表SQL 会写得非常痛苦。3.2 订单主表和订单明细表为什么必须拆开这是很多同学第一次做订单系统会踩的坑。假设用户在店铺 A 点了一份黄焖鸡、一份米饭又在店铺 B 点了一杯奶茶这两个店铺其实应该分成两个订单因为商家是不同的。这里引出两个设计一个用户的一次购物车提交可能被拆成多个 orders。按店铺维度拆单方便每个商家只看到自己的订单。每个订单包含多条菜品必须用 order_id 关联明细。orders 存总金额、状态、收货信息等概括数据order_item 存每一道菜的快照菜品名、价格、数量。为了“快照”我特意在 order_item 里冗余了 dish_name 和 dish_price而不是只存 dish_id。原因是菜品名称和价格以后可能被商家修改但用户历史订单必须保留下单那一刻的信息。如果只关联 dish_id商家改了菜品名用户看到的订单记录也跟着变这在业务上是不允许的。3.3 金额和状态字段的设计规范金额字段我强烈建议用int存“分”而不是double。Java 的double做浮点运算时会有精度问题比如 0.1 0.2 可能得到 0.30000000000000004。数据库同理decimal虽然可以但很多同学不注意精度直接存元最后对账对不上。我的做法是dish.price 和 orders.amount 都以“分”为单位存储前端展示时除以 100 转成元。订单状态位我用的是 int 类型加枚举常量public enum OrderStatus { CREATED(0, 待支付), PAID(1, 已支付), ACCEPTED(2, 商家已接单), MAKING(3, 制作中), DELIVERING(4, 配送中), COMPLETED(5, 已完成), CANCELED(6, 已取消); }不用 string 存状态的原因很简单枚举能防止脏数据会出现“状态码不存在”这种低级问题。接口返回时再把 int 翻译成用户看得懂的文本。3.4 配送轨迹单独建表别在配送单里拼字符串起初我偷懒想在 delivery 表里加一个track字段用 JSON 数组存位置点。后来发现根本没法做“按时间倒序查最近轨迹”SQL 也不方便。后来拆成 delivery_track 表每上报一个位置插一条记录。这样虽然数据量会变大但查询、统计、回放轨迹都很自然。配送单表最关键的两个字段是status和rider_id。rider_id为 null 代表还没被骑手抢非 null 代表已被认领。这个设计在抢单并发时非常有用后面我会详细说。4. 后端实现核心微信登录、订单状态机与库存扣减4.1 微信登录用 code2session 拿 openid再签发 JWT小程序端不能直接拿到用户手机号通常的做法是用微信的wx.login()获取临时 code然后把 code 传给后端。后端拿到 code 后调用微信接口jscode2session换取openid和session_key。这段逻辑看起来简单但很多项目会在这里绕弯路。核心代码如下PostMapping(/login) public Result login(RequestBody LoginDTO dto) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code dto.getCode() grant_typeauthorization_code; String res HttpUtil.get(url); WxSession wxSession JSONUtil.toBean(res, WxSession.class); // 根据 openid 查用户不存在则自动注册 User user userMapper.selectOne(new LambdaQueryWrapperUser() .eq(User::getOpenid, wxSession.getOpenid())); if (user null) { user new User(); user.setOpenid(wxSession.getOpenid()); user.setNickname(微信用户 RandomUtil.randomNumbers(6)); user.setRole(1); // 默认用户 userMapper.insert(user); } // 签发 JWT后续请求通过 token 识别身份 String token JwtUtil.createToken(user.getId(), user.getRole()); return Result.success(token); }注意几个关键点appid和secret一定不要硬编码在前端必须放后端配置里最好用ConfigurationProperties读取。登录成功后不要存 Session小程序端是移动端用 JWT 放在请求头Authorization里更合适。后续接口通过拦截器解析 token把 userId 和 role 放进 ThreadLocal业务代码里直接拿。4.2 下单接口的事务边界以及库存超卖的并发处理下单是最容易出 bug 的接口。很多教程这么写先查菜品库存判断库存大于购买数量然后减少库存插入订单。这在并发量稍微高一点的时候必出问题两个请求同时查到库存剩下 1 份都判断可以买最后超卖。在校园中午下课那个时间点热门窗口的菜是真有可能同一秒被几十个人下单。所以我用了一次“条件更新”来扣库存Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 根据购物车明细构造订单数据 // 2. 插入订单主表、订单明细表 // 3. 扣减库存这里用的是一条乐观锁式的 SQL int rows dishMapper.deductStock(dishId, quantity); if (rows 0) { throw new BizException(菜品库存不足); } // 4. 删除购物车中已下单的菜品 // 5. 返回订单信息 }对应的 SQL 是update dish set stock stock - #{quantity} where id #{dishId} and stock #{quantity}这行 SQL 的关键在于where stock #{quantity}。如果库存不足更新行数为 0事务回滚异常提示给用户。这比“先 select 再 update”安全得多也比纯悲观锁简单非常适合毕设阶段展示并发处理思路。下单接口必须加Transactional。你想想如果插入订单成功、扣库存失败事务不回滚的话手里没货但订单已经生成整个系统就出现脏数据了。4.3 订单状态机的流转设计订单状态不是随便 set 一个数字而是有明确流转规则的。我把它画成了一张状态表当前状态可执行动作下一状态CREATED 待支付模拟支付PAID 已支付PAID 已支付商家接单ACCEPTED 已接单PAID 已支付超时未接单CANCELED 已取消ACCEPTED 已接单商家出餐MAKING 制作中MAKING 制作中骑手取餐DELIVERING 配送中DELIVERING 配送中用户确认收货COMPLETED 已完成任意非终态用户/商家取消CANCELED 已取消代码层面我建议用一个状态机 service 来统一处理不要在每个 controller 里写order.setStatus(2)。比如“商家接单”这个动作public void accept(Long orderId, Long shopId) { Order order orderMapper.selectById(orderId); if (!order.getShopId().equals(shopId)) { throw new BizException(无权操作该订单); } if (order.getStatus() ! OrderStatus.PAID.getValue()) { throw new BizException(当前订单状态不支持接单); } order.setStatus(OrderStatus.ACCEPTED.getValue()); order.setAcceptTime(LocalDateTime.now()); orderMapper.updateById(order); }这样每个状态变化都走一道校验能挡住“跳过商家接单直接变成配送中”这种非法操作。4.4 商家端和骑手端的权限控制用户登录后只发一个 token但用户可能是学生、商家、骑手权限完全不同。我在拦截器里解析 token 后会放一个UserContext然后在商家接口上加角色判断public class ShopAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { User current UserContext.get(); if (current null || current.getRole() ! 2) { throw new BizException(无商家权限); } return true; } }用拦截器而不是在每个 controller 里if判断代码会干净很多。角色枚举我约定 1 用户、2 商家、3 骑手、4 管理员数据库 user 表直接存这个 int。5. 小程序端踩坑实录导航栏高度、列表加载更多与打包5.1 自定义导航栏高度适配校园订餐平台的页面基本都是“顶部标题 内容列表”结构默认导航栏字体和样式比较丑所以我用了自定义导航栏。这里有一个几乎所有人都要踩的坑不同手机的顶部状态栏高度不一样刘海屏和非刘海屏差距很大。正确做法是读取状态栏高度和胶囊按钮位置来计算导航栏高度const info wx.getSystemInfoSync(); const rect wx.getMenuButtonBoundingClientRect(); this.setData({ statusBarHeight: info.statusBarHeight, navBarHeight: (rect.top - info.statusBarHeight) * 2 rect.height });这段公式的意思是胶囊按钮顶部到状态栏底部的距离乘 2 加上按钮本身高度正好等于自定义导航栏的总高度。如果你直接写死 44px在 iPhone 14 Pro 上就会出现标题被刘海挡住的情况。5.2 列表分页加载更多onReachBottom 的正确姿势菜品列表、订单列表都是典型的滚动分页场景。很多人直接把所有数据一次性返回数据多的时候页面卡顿。我最终封装了一个分页请求逻辑onReachBottom() { if (this.data.loading || !this.data.hasMore) return; this.setData({ loading: true, page: this.data.page 1 }); this.loadList(); } loadList() { wx.request({ url: api.getOrderList, data: { page: this.data.page, pageSize: this.data.pageSize }, success: (res) { const list res.data.rows; this.setData({ list: this.data.list.concat(list), hasMore: list.length this.data.pageSize, loading: false }); } }); }有两个非常容易踩的坑不判断loading用户快速下滑时会连续触发多个请求导致列表重复。每次加载都覆盖list而不是concat导致只显示当前页数据。这两个问题在答辩演示时很容易暴露一定要提前处理。5.3 模拟支付与微信订阅消息校园订餐平台的支付在真实上线时需要微信支付商户号个人开发者根本申请不到。所以我的做法是先在后端预留pay接口但实现为“模拟支付”只要用户点击支付按钮就调用后端把订单状态从“待支付”改成“已支付”。答辩时老师问起你就说真实支付需要替换成微信支付统一下单接口前后端交互逻辑已经准备好。订阅消息也很重要。骑手接单、订单配送中这些关键节点小程序如果不在前台用户是收不到 WebSocket 消息的。所以可以接入一次性订阅消息wx.requestSubscribeMessage({ tmplIds: [订单状态提醒模板ID], success(res) { // 用户同意后后端可以通过 access_token 给用户发一次性订阅消息 } });注意订阅消息是“一次性”的用户订阅一次只能收到一条消息想再收需要再次授权。这是微信的规则不是 bug。5.4 真机调试与打包的注意事项小程序开发工具默认可以开启“不校验合法域名”本地请求http://localhost:8080很舒服。但真机预览时不能再依赖 localhost需要在同一个局域网内使用本机 IP。这个环节最常见的报错是 “url not in domain list”解决方法是在开发者工具里勾选“不校验合法域名”并且真机和电脑连同一个 Wi-Fi。如果你用了 uni-app 开发最后打包前一定要检查manifest.json里的小程序 AppID 是否填对很多学生项目卡在“打包后白屏”其实就是 AppID 没匹配。另外打包后要回微信开发者工具里再跑一遍不要在 H5 端跑通就觉得小程序没问题。6. 配送一体化的落地消息推送与骑手接单的简化实现6.1 一体化流程中的数据流“外卖配送一体化”不是一句口号而是要在数据层串起来。我的实现流程是用户下单成功后订单主表写入一条status PAID的订单。商家小程序轮询或通过 WebSocket 收到新订单点击接单订单变成ACCEPTED。商家出餐后订单变成MAKING同时系统生成一条配送单delivery.status WAITINGrider_id null。骑手小程序看到待抢配送单点击抢单执行条件更新rider_id。骑手取餐后上报状态订单变成DELIVERING。用户确认收货订单变成COMPLETED配送单变成FINISHED。这样订单表和配送表通过orders.rider_id和delivery.order_id关联每一步状态变化都有迹可循。6.2 实时通知WebSocket 和订阅消息怎么分工我在后端引入了 Spring Boot 的 WebSocket主要解决一个痛点商家和骑手端不能一直手动刷新页面。WebSocket 的用法不复杂Component ServerEndpoint(/ws/{userId}) public class OrderWebSocket { private static MapLong, Session sessionMap new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(userId) Long userId) { sessionMap.put(userId, session); } OnClose public void onClose(Session session, PathParam(userId) Long userId) { sessionMap.remove(userId); } public static void sendToUser(Long userId, String message) { Session session sessionMap.get(userId); if (session ! null session.isOpen()) { session.getBasicRemote().sendText(message); } } }用户在小程序端用wx.connectSocket连接路径里带 userId后端在订单状态变化时通过OrderWebSocket.sendToUser(shopUserId, 新订单)推送给对应商家。这样比前端每隔 5 秒轮询接口省很多资源答辩也更有亮点。订阅消息和 WebSocket 并不冲突WebSocket 负责小程序在线时的实时刷新订阅消息负责用户退出小程序后的通知。毕设可以先只做 WebSocket订阅消息作为扩展点说出来老师会认为你想得很全面。6.3 抢单并发用一条 SQL 保证只有一个骑手接单校园配送单一般会被多个骑手同时看到如果两个骑手同时点“抢单”不能两个人都变成骑手。这里我用的是和库存扣减类似的乐观锁思路update delivery set rider_id #{riderId}, status ACCEPTED, accept_time now() where id #{deliveryId} and rider_id is nullrider_id is null是核心条件。两个骑手同时执行这条 SQL 时数据库的行锁会让它们串行执行最终只有一个人更新成功。更新行数为 0 的骑手后端直接返回“手慢了单子已被抢”。如果你还想再高级一点可以用 Redis 的setIfAbsent做分布式锁但这套系统是单体应用数据库条件更新已经够了。不要为了秀技术把系统搞复杂。6.4 配送轨迹的简化实现骑手端每隔几秒上报一次经纬度后端插入delivery_track表。用户端拿到轨迹点后可以用地图组件绘制配送路线。这里有几个实现细节真机上获取定位需要用户授权scope.userLocation在校园里 GPS 精度一般足够。如果演示环境拿不到真实定位可以在骑手端做个“模拟移动”按钮每隔 2 秒更新一次经纬度。用户端不要每次都拉全量轨迹只拉当前配送单最近 20 条记录就够了。我没有做复杂的地图路径规划因为在校园场景下骑手从食堂到宿舍楼基本是直线不需要高德/腾讯的路线算法。你只要把经纬度点存下来前端画一条 polyline视觉上就已经很“一体化”了。7. 答辩之前你需要准备的演示脚本与高频追问7.1 演示顺序别从登录开始从业务开始我见过太多人答辩一上来就输入账号密码老师还没进入状态已经开始无聊了。正确的演示顺序是先让老师看到“这是一个能跑的完整业务”再补细节。我的演示脚本大概是这样的打开用户端小程序先浏览首页的食堂列表和店铺分类点进一家店铺看菜品详情。把招牌菜加入购物车提交订单模拟支付。切到商家端看新订单弹窗WebSocket 推送接单、出餐。切到骑手端看到待配送单抢单、取餐、开始配送上报一个模拟位置。切回用户端看到订单状态从“配送中”变成“已完成”确认收货。最后打开管理后台展示订单列表和统计数字。这个顺序的好处是老师从头到尾跟着业务走能直观看到“用户-商家-骑手”三方联动。如果时间不够优先演示第 2 到第 5 步这是整个项目的核心亮点。7.2 高频追问 Top 8我在准备答辩时整理了一批老师大概率会问的问题每个都写了回答思路高频问题参考回答要点订单表为什么拆成主表和明细表一个订单有多条菜品明细表存快照避免菜品修改影响历史订单库存超卖怎么解决使用update dish set stock stock - n where id ? and stock n通过条件更新保证不超卖事务放在哪一层Service 层加Transactional保证订单创建和库存扣减要么都成功要么都失败登录是怎么实现的小程序拿到 code 传给后端后端调微信 code2session 换 openid生成 JWT 返回前端为什么用 JWT 不用 Session接口无状态天然适合前后端分离和小程序多端场景订单状态怎么管理定义状态枚举在 Service 层统一校验和流转禁止在 Controller 里直接改状态WebSocket 解决了什么问题商家/骑手端无需轮询订单状态变化时后端主动推送体验更好如果用户下单后一直没有支付怎么办可以用定时任务扫描超过 30 分钟未支付的订单自动关闭这些问题都不需要背答案只要代码是你自己写的按实际思路回答老师基本不会为难。7.3 工程规范与文档整理最后一步是让项目“看起来像正规工程”而不是一堆乱放的文件。我强烈建议做好这几件事后端统一返回体Result包含code、message、data业务异常用全局RestControllerAdvice捕获。接口路径按模块划分/api/user、/api/shop、/api/order、/api/delivery。数据库脚本单独放sql/目录README 里写清楚启动步骤、账号角色、模拟数据说明。每个角色准备一个测试账号答辩时直接用别现场注册。这些工作不增加业务复杂度但能让评分老师在翻代码时一眼看出你有工程意识。我个人做完这套项目最大的体会是它真正的难点不是某个技术栈而是把“订单状态”这条主线理清楚。如果你能亲手画一张状态流转图把每次状态变化背后的接口、数据表字段和消息推送都对应起来这个项目的完成度就已经超过绝大多数毕设了。最后再分享一个实用建议开发时不要急着写代码先用两天时间把角色、流程、表设计定下来后面所有模块都会顺畅很多。
返回列表