1. 项目起因为什么选“大学食堂点餐”这个场景先说这台项目是怎么来的。当时某高校的后勤信息科找过来说食堂高峰期排队太严重想上一套线上点餐系统。接这个需求之前我也犹豫过——市面上外卖平台一大把为什么还要自己做聊完才发现校园食堂和外卖完全是两回事食堂档口需要短时间内集中出餐学生中午只有那几十分钟而且校内用户习惯高度集中、周而复始不是普通外卖平台能直接套用的。再看技术栈后端选SpringBoot是顺理成章的。团队对Java生态熟SpringBoot做REST接口、接MySQL、部署在校园内网服务器上都轻车熟路。Android端则是考虑到学生几乎人手一台安卓机做原生App能充分调用系统能力比如推送通知、相机扫码还能保证复杂页面刷新的流畅度。做之前我心里其实有一本账这个系统的核心不是“能下单”而是“高峰时段能撑住”“档口能高效接单”“学生不跑空”。所以整个项目拆成三块来设计和实施后端服务SpringBoot负责菜品管理、订单流转、用户体系、数据统计Android客户端面向学生用户完成逛菜单、下单、支付、订单跟踪窗口管理端面向食堂档口接收订单、更新出餐状态、管理上下架。本文按项目从头到尾的推进顺序来写涉及每一步为什么这么设计、具体怎么落地、踩过哪些坑。适合想用一个真实业务场景入门SpringBootAndroid全栈开发的同学也适合未来要接手校园类O2O项目的开发者参考。2. 技术选型背后SpringBoot和Android端的配合逻辑2.1 为什么是SpringBoot而不是其他后端框架如果你只是想快点启动一个试点项目SpringBoot确实是最稳的选择理由很实际内置Tomcat不用单独配容器Starter依赖让集成MyBatis、Redis、JWT这些组件快很多Spring的生态成熟遇到问题资料也多。这个项目里我主要用到以下组件组件用途关键理由Spring Web提供RESTful接口天然适合Android端JSON交互MyBatis-Plus数据持久化查询灵活内置CRUD降低开发量Redis缓存菜品和Token降低数据库压力支撑高峰期并发JWT用户身份认证无状态认证适合App端MySQL数据存储校园级体量完全足够实际开发中我的经验是不要一上来就上微服务、消息队列校园食堂点餐的峰值并发再高也只是几百上千的量级单机SpringBoot加适量Redis缓存完全扛得住架构简单出问题也好排查。2.2 Android端与服务端的交互设计Android端与服务端之间我采用了标准的RESTful API规范数据格式统一JSON接口路径按资源来组织。核心接口分成这几组功能模块接口路径示例说明用户认证POST /api/user/login学号密码登录取Token菜品浏览GET /api/meal/list?category1按分类拉取菜品下单POST /api/order/create创建订单同时提交多个菜品订单查询GET /api/order/my?status0查询当前用户订单列表出餐更新PUT /api/order/status档口更新订单状态至“待取餐”或“已完成”菜品管理POST /api/dish/upshelf档口端上下架菜品后端接口设计有一个非常重要的原则数据尽量在服务端算好客户端只做展示。比如购物车合计金额、订单剩余等待时间这些统统由后端返回Android端不重复计算。这样做不仅避免两端金额不一致的麻烦也减少App端的逻辑复杂度网络异常时还容易排查。从网络通信层面来说Android端我采用Retrofit配合OkHttp来发送请求底层连接复用、超时重试都交给OkHttpRetrofit则负责把JSON自动解析成Java Bean。在模拟器里调试时访问本机后端要注意不能用localhost而要用10.0.2.2这是Android模拟器访问宿主机的一个固定映射我第一次联调时在这里耗了快半天。3. Android端开发从页面流程图到MVP分层落地3.1 学生端的功能模块拆解打开App首先要解决的是“我是谁”的问题这里直接用学号加密码登录。登录之后主界面底部三个页签首页菜单、订单中心、个人中心。我不建议把入口设计得太复杂学生的操作路径非常固定——今天吃什么、点单、等出餐、取餐走人。首页菜单部分用RecyclerView做菜品列表顶部横向滑动切换分类套餐、盖饭、面食、饮品每个条目展示菜品图、名称、月售和价格点击进入详情。购物车用底部弹层来展示方便一边浏览一边添加。“去结算”按钮会跳转确认订单页确认后提交到后端。订单中心里按状态分成“待取餐”“已完成”“已取消”三个页签。这个设计是从用户体验角度来定的学生最关心的是“我的餐好没好”所以待取餐列表要在最显眼的位置并且要能看到实时状态变化。3.2 MVP分层结构为什么要这样隔离项目代码结构上Android端采用MVP模式包结构如下com.example.canteenapp ├── model // 数据模型和网络请求Retrofit服务接口 ├── view // Activity和Fragment只负责UI展示和用户交互 ├── presenter // 业务逻辑处理持有View和Model的引用 ├── adapter // RecyclerView适配器 ├── utils // 工具类如SharedPreferences存取Token └── constant // 接口地址等常量配置MVP模式这里有一个很实际的收益档口端和学生端的登录逻辑、菜品列表逻辑其实是相似的只是UI不同。把业务逻辑全部下沉到Presenter层后两个端可以复用同一套Model和Presenter写代码时只改View层就够减少了大量重复劳动。后来我们确实在同一个工程里通过多Module方式复用了核心逻辑打包成学生端和档口端两个App。还有一个经验Token不要一股脑只存在内存里App被系统回收后再打开就要重新登录体验很糟糕。我统一用SharedPreferences存Token和用户基本信息每个网络请求都在拦截器里自动带上Authorization头后端验签失败时统一处理为“登录过期”引导用户回登录页。这样做的代码虽有些许增加但换来的是用户“无感续登”的体验非常值得。3.3 页面轮转与刷新策略食堂点餐场景里订单状态是实时变化的。Android端我使用了轮询策略——学生停留在订单详情页时每10秒请求一次后端获取最新状态切到后台时通过onStop回调暂停轮询回到前台再恢复。用10秒而不是1秒是为了避免长时间挂起时对服务端造成无谓压力。想更省资源的话还可以用Android的WorkManager做定时后台同步但实际场景下学生取餐的等待时长都在几分钟内前台轮询完全够用。下拉刷新我统一放在订单列表页实现并配合一个非常朴素的交互列表数据为空时显示“暂无订单”占位图避免用户以为是网络出错。这一点虽然简单但实际使用中很能降低学生对App的困惑感。4. 后端核心SpringBoot接口、数据库表与订单状态机设计4.1 数据库表结构与设计要点数据库我总共规划了六张核心表。这里说一下我认为最需要注意的两个设计决策第一订单需要保存“下单时的菜品快照”。菜品名称和价格可能会调整如果订单关联的是实时菜品表那用户在历史订单里看到的菜价和菜名可能对不上这是常规设计里很容易忽略的问题。我在order_item表里单独冗余存储了菜名、单价、数量这样无论菜品后续怎么改订单数据始终准确。第二订单表增加window_id字段标识该订单属于哪个档口。校园食堂是多档口并行的学生可能在同一订单里从A窗口买饭、B窗口买饮料所以一组订单明细需要按窗口拆分成多条“子订单”。我采用主订单表订单明细表的方式主订单只记录总价和状态明细表通过window_id和order_id确定某个档口收到哪些菜品各档口只需处理自己范畴的明细互不干扰逻辑非常清晰。4.2 订单状态机的设计这块是整个项目的核心逻辑也是我前期设计耗时最长的地方。订单状态我定义了五个状态状态值含义说明0待支付订单创建但未支付1待接单已支付档口尚未处理2待取餐档口已接单并出餐完毕待学生取餐3已完成用户确认取餐4已取消用户取消或超时未支付自动取消状态流转我严格限制在以下几条路径上待支付可以取消或支付待接单可以接单变为待取餐也可以退款取消待取餐只能变成已完成。任何非法的状态变更都会被后端拒绝。为什么这么严格因为校园订单涉及食品安全和财务对账宁可让流程繁琐一点也不要让状态漂移导致对不上账。4.3 高并发下的防超卖与幂等设计食堂到饭点瞬间请求会集中暴增尤其是下单操作。我在下单接口上做了两个非常实用的保护机制第一Redis分布式锁防重复下单。同一个用户在2秒内对同一档口重复提交订单直接拦截。实现方式很简洁用SETNX以用户ID窗口ID为key设置过期时间拿到锁才允许写订单表。这样即使App端因为网络延迟发起重试也不会生成重复订单。第二库存扣减采用“数据库条件更新”方式避免超卖。菜品表里有一个剩余数量字段扣减时执行的SQL类似UPDATE dish SET remaining remaining - 1 WHERE id ? AND remaining 0受影响行数为0时就说明库存不足后端直接返回“已售完”。不用先查再改避免并发时读到同一个旧值。这个方案虽然简单但足够可靠而且没有用到事务锁性能上也不吃亏。缓存的策略上因为菜单本身变化不频繁我做了Redis缓存菜品列表、分类、详情缓存key按分类维度切分失效策略是主动失效——档口上架或下架菜品时主动删除相关缓存。这样既能支撑高并发读又不会出现缓存与数据库长期不一致的问题。5. 档口端与多角色权限登录认证和细粒度控制5.1 双角色账户体系学生端和档口端是两种完全不同的用户类型。学生只关心自己下单取餐档口则要管理菜品、处理所有订单。因此我在用户表上增加了一个role字段来区分角色登录接口返回的Token里包含角色信息后端通过拦截器解析JWT后再结合接口上的权限注解做控制。实现方式很标准定义一个RequireRole注解标注在Controller方法上参数为“student”或“window”拦截器里统一校验当前用户角色。比如下单接口只能学生调用档口接单接口只能window角色调用。如果角色不匹配统一返回403错误码客户端收到后提示“无权操作”。AuthInterceptor本身是个OncePerRequestFilter的子类只做三件事从Header里取Token、验签解析用户ID、把用户信息放入ThreadLocal供后续业务使用。5.2 档口App的独立设计档口端是和学生端独立的App界面信息密度更高。主界面是分状态的订单流水列表每条订单突出展示菜品明细和下单时间并提供“接单”“出餐”两个大按钮方便后厨戴着厨师手套也能准确点击。接单后订单进入“待取餐”状态餐做好后档口再点一次“出餐完成”学生端的订单状态就同步变了。这里有一个很重要的体验细节档口端和学生端的数据是实时联动的学生端用轮询10秒查一次档口端却没有使用推送而是每次“接单”或“出餐”操作后主动刷新列表。是因为档口端屏幕常亮、使用频率高轮询损耗更大而且档口员工并不依赖自动弹新单——他们只关心“我要处理的下一条是什么”。系统上线后也是这么运营的效果很好。5.3 后端接口的权限防越权检查除了角色控制行级权限也很重要。一个学生不应该能查看别人的订单一个档口不应该能改别家档口的菜品。我在所有涉及订单和菜品的查询接口上都强制带上当前登录用户ID作为查询条件而不是由前端把“我要查看的订单ID”传进来后就无条件返回数据。尤其要注意“水平越权”这个问题。比如某档口试图把不属于自己的菜品ID传进来做下架操作后端必须再做一次菜品归属校验先查出菜品所属的窗口ID与当前登录用户的窗口ID比对不一致就拒绝。这种隔层防护虽然开发时多写几行代码但却是仿冒请求的终极防线强烈建议做权限相关项目时都加上。6. 联调过程中不可忽视的坑模拟器、真机与网络权限6.1 网络调试的几个典型误区开发阶段有80%的调试时间都耗在网络通信上而且踩的坑非常典型这里把我的排查路径完整列出来。第一次跑通学生端登录时明明后端接口在浏览器里能访问App端却一直报超时。排查过程是这样的先确认后端服务的启动IP和端口没绑错然后从模拟器里用curl直接访问——模拟器自带终端执行curl http://10.0.2.2:8080/api/health看返回结果。如果能通说明网络链路没问题问题出在Android端代码上如果不能通抓包看底层请求尤其要检查模拟器的网络配置。最后定位到问题是模拟器没开“桥接网络”走的NAT模式把宿主机回环地址绕过去了。换成10.0.2.2后立刻通了。真机调试时还有另一个坑Android 9及以上默认禁止明文HTTP流量必须显式配置networkSecurityConfig允许对开发服务器IP使用明文连接。这个配置漏了的话真机上所有HTTP请求都会直接被拦而且报错信息并不直观很容易让人误判成后端问题。建议开发阶段直接允许全部明文流量发布前再收紧。6.2 后端日志与接口联调技巧联调时我会全程打开SpringBoot的日志级别尤其是SQL日志MyBatis的SQL和执行参数都打到控制台里。这样App端一旦报错后端能立刻定位到数据库查询是否异常。关于接口返回体我统一规定了一个Result 结构包含code、message和data三个字段。code为0表示成功非0表示业务失败。Android端全局拦截这个结构如果code非0自动弹出Toast提示消息不需要每个请求单独处理。后端校验失败的异常也被全局异常处理器统一捕获并转为Result返回不会把堆栈直接抛给客户端。这个约定非常基础但能极大减少两端扯皮的时间。7. 上线后发现的坑和复盘订单取消、结算金额和数据库事务7.1 自动取消与定时任务的设计缺陷系统上线第一周我们就发现了一个问题学生下单后如果不支付订单会一直挂在那里占着档口的待处理列表而且实际库存已经被预扣了。原来的设计里我预留了“超时未支付自动取消”的功能但实现得很粗糙直接用了Spring的Scheduled定时任务每5分钟扫描一次把创建时间超过15分钟的待支付订单全部置为取消。问题出在扫描粒度假设订单在14分59秒时正好被用户支付成功同时定时任务也扫到了这条两个操作并发修改同一条订单数据就会出现“支付成功但订单被取消”的脏数据。后来是如何修复的我引入了一版乐观锁控制UPDATE order SET status 4 WHERE id ? AND status 0 AND create_time NOW() - INTERVAL 15 MINUTE只有状态仍为待支付时这条记录才能被更新为取消竞态条件下先到先得不会出现状态互踩。类似的问题在财务系统里非常常见属于典型的并发修改问题开发者很容易忽略。7.2 结算金额为何必须由后端重新计算购物车合计在后端下单时重新计算而不是信任客户端传上来的总价这也是上线后复盘的一个重点。学生端传订单明细时只传菜品ID和数量后端根据数据库里的实时价格累加并重新计算总价写入订单表。之所以这么做是因为客户端传参很容易被篡改而且菜品价格随时可能调整以客户端缓存的价格来计算会导致订单金额漂移。另外支付时使用第三方模拟支付的场景回调通知必须校验金额。我曾经在回调接口里发现学生伪造回调参数的情况虽然试运行阶段没有实际资金损失但提醒我后端必须也只以自己数据库中的订单金额作为最终结果。7.3 数据库事务的边界下单与扣库存必须原子生成主订单、插入订单明细、扣减库存这三个操作必须放在同一个事务里。一开始我把扣库存的逻辑拆到了独立的Service方法里并加了Transactional但没注意到Spring事务默认只对RuntimeException回滚如果扣库存抛的是检查异常事务不会自动回滚。后来我统一改用自定义业务异常让它继承RuntimeException并在事务边界内包住整段下单逻辑。这样只要任何一个环节失败之前插入的数据都会撤销不会出现“订单存在但没扣库存”或反过来“库存扣了但订单没生成”的尴尬状态。用代码表示核心事务Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto, Long userId) { // 1. 生成主订单记录状态为待支付 // 2. 批量插入订单明细校验菜品归属 // 3. 条件更新库存失败则抛出业务异常 // 4. 返回订单ID }有了事务保障至少数据一致性这个底线是拿到手了。8. 复盘与后续优化方向这个项目从立项到稳定运行约用了六周时间期间经历了原型设计、开发、内部测试、试点运行几个阶段。上线后三个关键数据我记得很清楚中午高峰时段接口平均响应时间从最开始超过2秒降到约700毫秒学生端日均下单量稳定在1200单以上档口平均出餐等待时间缩短了三分之一。这几个数字基本证明了这套设计和实现是站得住脚的。关于后续优化有几个方向我个人觉得值得做如果读者准备在此基础上继续迭代可以按优先级参考优先级优化方向具体思路高后端引入消息队列高峰订单量过大时可削峰例如把下单请求写入队列异步落库高App端引入推送通知轮询改成长连接推送进一步降低服务器压力和客户端耗电中菜品图片OSS化当前菜品图片直接存服务器本地量大了会占磁盘建议迁对象存储中档口销售数据看板按周、按月统计各档口营收和菜品排行辅助食堂管理决策低多食堂扩展当前单校区试点未来要支持多食堂、跨食堂聚合点餐低接入真实支付渠道模拟支付替换为真实聚合支付需要额外对接支付回调、对账逻辑这一点我特别想提醒后来者不要把技术框架选型看得太重真正决定项目成败的反而是那些看起来不起眼的细节——库存怎么扣、订单状态怎么流转、事务边界划在哪里、权限校验有没有做到行级。这些问题是任何业务系统都绕不开的在校验和并发上多花时间上线以后可以少熬夜。最后分享一个我个人的习惯联调阶段一定保留一份完整的接口文档哪怕是手写的表格也要把所有接口路径、参数、返回值、错误码都记清楚。校园类项目人员流动快交接时文档比代码更容易看懂这个项目最后能顺利交付并平稳运行一半功劳要记在文档上。