1. 项目起因:为什么选“大学食堂点餐”这个场景
先说这台项目是怎么来的。当时某高校的后勤信息科找过来,说食堂高峰期排队太严重,想上一套线上点餐系统。接这个需求之前我也犹豫过——市面上外卖平台一大把,为什么还要自己做?聊完才发现,校园食堂和外卖完全是两回事:食堂档口需要短时间内集中出餐,学生中午只有那几十分钟,而且校内用户习惯高度集中、周而复始,不是普通外卖平台能直接套用的。
再看技术栈,后端选SpringBoot是顺理成章的。团队对Java生态熟,SpringBoot做REST接口、接MySQL、部署在校园内网服务器上都轻车熟路。Android端则是考虑到学生几乎人手一台安卓机,做原生App能充分调用系统能力,比如推送通知、相机扫码,还能保证复杂页面刷新的流畅度。做之前我心里其实有一本账:这个系统的核心不是“能下单”,而是“高峰时段能撑住”“档口能高效接单”“学生不跑空”。所以整个项目拆成三块来设计和实施:
- 后端服务:SpringBoot负责菜品管理、订单流转、用户体系、数据统计;
- Android客户端:面向学生用户,完成逛菜单、下单、支付、订单跟踪;
- 窗口管理端:面向食堂档口,接收订单、更新出餐状态、管理上下架。
本文按项目从头到尾的推进顺序来写,涉及每一步为什么这么设计、具体怎么落地、踩过哪些坑。适合想用一个真实业务场景入门SpringBoot+Android全栈开发的同学,也适合未来要接手校园类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?category=1 | 按分类拉取菜品 |
| 下单 | POST /api/order/create | 创建订单,同时提交多个菜品 |
| 订单查询 | GET /api/order/my?status=0 | 查询当前用户订单列表 |
| 出餐更新 | PUT /api/order/status | 档口更新订单状态至“待取餐”或“已完成” |
| 菜品管理 | POST /api/dish/upshelf | 档口端上下架菜品 |
后端接口设计有一个非常重要的原则:数据尽量在服务端算好,客户端只做展示。比如购物车合计金额、订单剩余等待时间这些,统统由后端返回,Android端不重复计算。这样做不仅避免两端金额不一致的麻烦,也减少App端的逻辑复杂度,网络异常时还容易排查。
从网络通信层面来说,Android端我采用Retrofit配合OkHttp来发送请求,底层连接复用、超时重试都交给OkHttp,Retrofit则负责把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化 | 当前菜品图片直接存服务器本地,量大了会占磁盘,建议迁对象存储 |
| 中 | 档口销售数据看板 | 按周、按月统计各档口营收和菜品排行,辅助食堂管理决策 |
| 低 | 多食堂扩展 | 当前单校区试点,未来要支持多食堂、跨食堂聚合点餐 |
| 低 | 接入真实支付渠道 | 模拟支付替换为真实聚合支付,需要额外对接支付回调、对账逻辑 |
这一点我特别想提醒后来者:不要把技术框架选型看得太重,真正决定项目成败的反而是那些看起来不起眼的细节——库存怎么扣、订单状态怎么流转、事务边界划在哪里、权限校验有没有做到行级。这些问题是任何业务系统都绕不开的,在校验和并发上多花时间,上线以后可以少熬夜。
最后分享一个我个人的习惯:联调阶段一定保留一份完整的接口文档,哪怕是手写的表格,也要把所有接口路径、参数、返回值、错误码都记清楚。校园类项目人员流动快,交接时文档比代码更容易看懂,这个项目最后能顺利交付并平稳运行,一半功劳要记在文档上。