ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android电子点餐系统:订单状态机与Spring Boot端到端实现

Android电子点餐系统:订单状态机与Spring Boot端到端实现 简介一份基于Android平台实现的电子点餐系统毕业设计资源面向具备一定编程基础并希望深入Android应用开发的学习者也适合餐饮信息化系统设计人员作为参考。系统采用C/S架构以Java语言、Eclipse开发环境和SQLite数据库为技术栈实现了菜品分类、单价、口味、已点数量和总价等核心点餐功能能够有效弥补传统纸质菜单易脏乱、服务效率低等不足为餐厅信息化升级提供可行的设计方案。压缩包内共有1个文件文件类型为docx包体大小58KB文档包含中英文摘要、系统功能结构图、数据流程图、主要功能模块实现、界面设计细节与完整测试过程内容层次分明、结构完整。已有170人学习浏览既适合用作毕业设计撰写与答辩参考也可作为Android开发课堂案例帮助读者掌握移动端点餐系统从需求分析到具体落地的全流程。1. 基于Android的电子点餐系统先想清楚下单那一刻的状态流转“基于Android的电子点餐系统设计与实现”在真正跑起来之前会让人误以为重点在列表滑动和多选购物车其实在下单那一秒之前的所有设计都是在为状态流转铺路。这里直接给一套能落地的思路先定义菜单、桌台、订单和订单明细的领域模型再为订单设计一个写进代码的状态机然后用 Retrofit ViewModel 写 Android 客户端最后用 Spring Boot 服务端把状态推进做成并发安全。开发环境就是 Android Studio数据库用 MySQL四个部分全部能在本地联调。这套方案适合已经有 Android 基础、想独立走通端到端点餐闭环的工程师读完可以直接对着自己的项目改。2. 电子点餐的领域模型与订单状态机设计2.1 先定义菜单、桌台、订单三张核心表电子点餐和普通电商系统的差异在于多了一个“桌台”维度。客人到店坐下扫码拿到桌台号不需要注册账号桌台号就承担了会话身份。这个特性直接影响表结构设计订单要冗余桌台信息但不要为了兼容“外卖模式”一开始就引入用户表否则登录注册会把核心流程拖慢。订单明细必须冗余下单时的菜品价格。菜单调价是常态如果订单明细只存 menu_item_id结账时再去关联菜品表历史订单金额就会被新价格污染。下面是四张表的最小建表 SQL直接跑在 MySQL 8 上。CREATE TABLE menu_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, category_id INT NOT NULL, stock INT NOT NULL DEFAULT 999, status TINYINT NOT NULL DEFAULT 1, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE dining_table ( id BIGINT PRIMARY KEY, table_no VARCHAR(16) NOT NULL UNIQUE, qr_url VARCHAR(255) NOT NULL ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, table_id BIGINT NOT NULL, status TINYINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, menu_item_id BIGINT NOT NULL, quantity INT NOT NULL, item_price DECIMAL(10,2) NOT NULL );字段说明重点说一下三处menu_item.stock 用于库存预警日常点餐场景不建议每次下单都锁库存否则高峰时段并发写会压在菜品行上orders 表里特意保留 order_no 唯一索引这是服务端幂等兜底的最后一环order_item.item_price 保存实付时刻的菜品单价后续对账以它为准。表关键行为设计原因menu_item下架用 status 而不是 DELETE保留历史订单可回溯避免外键悬空dining_tabletable_no 唯一二维码内容直接绑定桌台号二维码重打不影响业务ordersstatus 由状态机维护防止前端任意改状态导致账目错乱这里不需要建用户表Android 端把桌台号作为请求头参数传递即可省掉 token 流程也更贴近堂食场景。2.2 订单状态机把流转约束写进代码而不是靠口头约定很多点餐项目把订单状态做成一个可以随意 set 的字段任何接口都能改。高峰期服务员催单、后厨点击制作、客户刷新页面同时发生状态就会被后写入的请求覆盖。比如 PAID 状态被误写成 CANCELLED后厨就再也看不到这张单。设计状态机要明确“谁在什么动作下把订单从什么状态推到什么状态”并且只允许定义好的路径发生。当前状态动作下一个状态PENDING待支付支付回调成功PAIDPENDING待支付超时/主动取消CANCELLEDPAID已支付后厨接单MAKINGMAKING制作中出餐SERVEDSERVED已上菜客人结账COMPLETEDPAID / MAKING异常退单CANCELLEDCANCELLED 是终态不允许再变回 PAID否则会出现后厨做完了但账单被取消的冲突。Kotlin 端可以这样定义enum class OrderStatus(val code: Int) { PENDING(0), PAID(1), MAKING(2), SERVED(3), COMPLETED(4), CANCELLED(5); fun canTransitTo(target: OrderStatus): Boolean { return TRANSITIONS[this]?.contains(target) true } companion object { private val TRANSITIONS: MapOrderStatus, SetOrderStatus mapOf( PENDING to setOf(PAID, CANCELLED), PAID to setOf(MAKING, CANCELLED), MAKING to setOf(SERVED, CANCELLED), SERVED to setOf(COMPLETED) ) } }这段代码把状态迁移表收敛到一处客户端和服务端共用同一份规则避免“客户端知道能取消、服务端不知道”这类不一致。canTransitTo 返回 false 时接口应该返回明确错误码而不是偷偷把状态覆盖掉。2.3 接口协议统一响应包装客户端少三个 if elseAndroid 端判断接口成败最忌讳的是先看 HTTP 状态码再看业务 code。点餐场景里“菜品已下架”和“订单超时”都属于业务失败HTTP 状态码都会是 200但 data 字段的语义不同。后端统一返回这样的包装体{ code: 200, message: ok, data: { orderNo: A12 20250512 615, status: 1 } }code 只表达业务结果200 表示成功其余表示业务拒绝或系统异常。客户端拿到响应后只判断 codedata 里的字段再单独解析。接口协议里还要约定时间字段统一用毫秒时间戳而不是格式化日期的字符串避免 Android 端 SimpleDateFormat 在不同时区下解析出错。3. Android 客户端用 Retrofit ViewModel 跑通点餐主流程3.1 菜单列表从接口到页面的最小链路客户端网络层建议直接用 Retrofit配合 Kotlin 协程的 suspend 函数比回调嵌套清楚得多。先定义接口interface MenuApi { GET(api/menu/items) suspend fun getMenuItems(): ApiResponseListMenuItem }ViewModel 只暴露 StateFlow页面订阅状态并渲染class MenuViewModel(private val repo: MenuRepository) : ViewModel() { private val _menuState MutableStateFlowMenuUiState(MenuUiState.Loading) val menuState: StateFlowMenuUiState _menuState fun loadMenu() { viewModelScope.launch { _menuState.value MenuUiState.Loading _menuState.value try { MenuUiState.Success(repo.fetchMenu()) } catch (e: Exception) { MenuUiState.Error(e.message ?: 网络异常) } } } }Retrofit 的 suspend 方法负责把网络请求切到 IO 线程ViewModel 里不需要手动写 Dispatchers.IO。MenuRepository 在中间做数据源切换后续要加 Room 缓存时只需要改 Repository 内部实现Activity 和 Fragment 不用动。网络超时参数按餐厅场景设置连接 10 秒、读 10 秒、写 10 秒。点餐高峰期 WiFi 质量不稳定超时设太短会出现菜单反复加载失败。3.2 加载、空态与错误态不要用进度条遮住所有边界菜单页最常见的三个状态是初次加载、下拉刷新、接口报错。很多项目让 ProgressBar 全程可见失败后就白屏用户连重试入口都找不到。建议用密封类把页面状态一次定义完整sealed class MenuUiState { object Loading : MenuUiState() data class Success(val items: ListMenuItem) : MenuUiState() data class Error(val message: String) : MenuUiState() }页面在 collect 到这个状态时用 when 分支决定显示内容页面状态菜单区域展示SwipeRefreshLayoutProgressBarLoading骨架屏或空白关闭显示Success 且列表非空菜单列表开启隐藏Success 且列表为空空态文案开启隐藏Error错误文案加重试按钮关闭隐藏初次加载看 ProgressBar下拉刷新看 SwipeRefreshLayout 自己的转圈不要再叠加一层 Dialog 进度框。空态和错误态一定要让用户能操作只显示一行文字没有按钮用户只能杀掉进程重进。3.3 购物车与提交订单用请求锁把重复下单挡在门外Android 端防重复提交只靠禁用按钮远远不够。按钮点击回调在 View 层网络请求还在途时 Activity 因为配置变化重建按钮状态就丢了。更可靠的做法是在 ViewModel 里加请求锁class OrderViewModel(private val repo: OrderRepository) : ViewModel() { private val submitLock Mutex() private var submitting false suspend fun submitOrder(cart: Cart): ResultString submitLock.withLock { if (submitting) returnwithLock Result.failure(IllegalStateException(请勿重复提交)) submitting true try { Result.success(repo.submit(cart)) } finally { submitting false } } }Mutex 保证同一时刻只有一个协程能进入提交逻辑submitting 变量防止排队中的第二次点击继续发请求。注意这个 submitting 没有被标记为 volatile但因为它的读写都发生在 withLock 临界区内靠 Mutex 的内存可见性已经保证不需要额外加注解。按钮禁用是第一道防线这里的请求锁才是真正拦住重复网络请求的机制配合服务端唯一订单号能做到双击只产生一单。3.4 扫码桌台并把二维码内容转成下单请求扫码推荐用 ZXing 的 Intent 方式调起系统扫码界面集成成本低不用自己处理相机权限。扫码结果只是一个字符串重点在解析协议。二维码内容建议只放桌台号不要放完整订单数据否则二维码被拍下来就能伪造一单。约定格式为 table:A12override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { if (requestCode SCAN_REQUEST resultCode RESULT_OK) { val rawResult data?.getStringExtra(SCAN_RESULT) ?: return val tableNo rawResult.removePrefix(table:).trim() viewModel.bindTable(tableNo) } }解析逻辑要做容错removePrefix 之后如果结果为空或长度异常提示“二维码无效”而不是直接崩溃。有些真机从相册选二维码图片时返回的 URI 可能带 content:// 前缀需要走 ContentResolver 读取不能盲目把字符串当 File 路径处理否则会报 FileNotFoundException。4. 服务端接口实现与联调验证4.1 Spring Boot 提供菜单查询与下单接口服务端用 Spring Boot 3.x 即可数据库用 MySQL不需要引入消息队列。菜单和下单是最核心的两个接口Controller 保持薄业务逻辑放到 ServiceRestController RequestMapping(/api) public class OrderController { GetMapping(/menu/items) public ApiResponseListMenuItem listMenu() { return ApiResponse.success(menuService.listOnShelf()); } PostMapping(/orders) public ApiResponseString createOrder(RequestBody CreateOrderRequest req) { return ApiResponse.success(orderService.create(req)); } }ApiResponse 是统一响应包装结构就是前文约定的 code、message、data。CreateOrderRequest 里至少包含 tableNo 和 cartItemscartItems 每个元素带 menuItemId 与 quantity。创建订单时先校验桌台是否存在、菜品是否在上架状态然后计算金额、生成订单号、插入订单和明细整个过程要落在同一个事务里防止出现订单主表有记录但明细缺失。4.2 订单状态推进的并发控制用条件更新代替先查后改后厨客户端和 Android 顾客端可能同时操作同一张订单比如顾客取消的同时后厨正在接单。如果先 SELECT 再 UPDATE两次操作中间状态已经被对方改掉最后一次写入就会覆盖前一次结果。正确做法是让 UPDATE 语句带上期望的当前状态Modifying Query(UPDATE Order o SET o.status :newStatus, o.updatedAt CURRENT_TIMESTAMP WHERE o.id :orderId AND o.status :expectedStatus) int updateStatusIfEquals(Param(orderId) Long orderId, Param(expectedStatus) OrderStatus expectedStatus, Param(newStatus) OrderStatus newStatus);这个方法返回 int 是受影响行数。返回 1 表示状态推进成功返回 0 表示当前状态已经不是预期值Service 层收到 0 就抛出业务异常并让接口返回 code 409。这样即使两个请求同时到达数据库InnoDB 的行锁也会让后一个请求的 WHERE 条件不满足直接更新失败。参数含义取值例子orderId订单主键1024expectedStatus调用方认为的当前状态PAIDnewStatus目标状态MAKING调用方在每次状态推进前先查一次订单状态作为 expectedStatus 传入再调用这个方法。这种方法把并发控制从业务代码下沉到了 SQL 层比自己加分布式锁简单得多堂食点餐的单店规模完全够用。4.3 Android 与后端本地联调的检查点Android 模拟器访问电脑本地的 localhost 是走不通的要用 10.0.2.2真机要指定电脑的局域网 IP。更省事的方式是用 Android 调试桥做端口反向映射# 查看设备是否在线 adb devices # 真机通过 USB 通道把电脑上的 8080 端口映射到手机的 localhost:8080 adb reverse tcp:8080 tcp:8080 # 只看点餐相关日志避免被系统日志刷屏 adb logcat -s OkHttp OrderViewModel注意 Android 9 之后默认禁止明文 HTTP局域网联调需要在 AndroidManifest.xml 的 application 节点加 android:usesCleartextTraffictrue。这个配置只应该在开发环境打开发布到生产环境前改成 false接口全部走 HTTPS。联调时建议按这个顺序验证第一菜单接口返回 code 200 且列表字段完整第二进入下单页后快速点击两次提交确认只产生一个订单号第三把服务端数据库里订单状态手动改成 PAID再调用取消接口确认返回 409 而不是默默覆盖。5. 上线前把两个易错点钉死缓存与幂等5.1 菜单页先走 Room 缓存接口失败不再白屏餐厅后厨出菜慢、网络差的情况经常发生菜单接口一挂整个点餐流程就瘫了。常见做法是让菜单页优先读 Room 缓存接口成功再刷新缓存接口失败直接展示缓存数据。Room 需要一张本地菜单表DAO 里先查缓存Dao interface MenuItemDao { Query(SELECT * FROM menu_item WHERE status 1 ORDER BY category_id) suspend fun getLocalMenu(): ListCachedMenuItem Insert(onConflict OnConflictStrategy.REPLACE) suspend fun replaceAll(items: ListCachedMenuItem) }接入缓存后有一个坑要特别注意缓存数据不能无限期生效。菜品价格调整后旧缓存会显示错误价格。可以在每次打开菜单页时记录缓存时间超过 30 分钟就强制走网络刷新同时服务端在菜单接口里返回一个 version 字段客户端发现 version 变了就清掉 Room 里的旧表重新写入。5.2 用“桌台号 时间戳 随机数”生成订单号日志里一眼定位订单号生成不用依赖数据库自增 ID那样既泄露订单量又在多表关联时容易搞混来源。客户端在扫码入座后就生成一次订单号规则是桌台号加上当前毫秒时间戳再拼三位随机数fun createOrderNo(tableNo: String): String { val timestamp System.currentTimeMillis() val suffix (100..999).random() return ${tableNo}-${timestamp}-${suffix} }生成结果类似 A12-1747036800123-518桌台号负责定位物理位置时间戳负责定位下单时刻随机数负责防止同一毫秒内重复。服务端在 orders 表的 order_no 上加唯一索引两个客户端同时提交同样的订单号时数据库会拒绝第二条插入这是整个防重复机制的最终兜底。排查支付回调问题时拿着这个订单号能直接在数据库和后厨日志里同时找到对应桌台和时刻比用自增 ID 快得多。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进