扫码点餐看起来只是"贴一张二维码,让顾客自己下单",但真正落到工程实现上,它和普通电商商城的结构差异很大。普通商城的订单由"用户 → 购物车 → 结算"产生,订单和物理空间没有强绑定;扫码点餐的订单在创建那一刻就必须回答一个问题:这张订单属于哪家门店的哪张桌子。桌台是空间锚点,也是后续所有状态流转和硬件联动的起点。
把这条链路拆开,有三个问题会反复出现,并且直接决定系统能不能在高峰期稳定运行:桌台二维码怎么做到可校验、不可篡改;堂食订单的状态机怎么设计才不会把后厨卡死;厨房打印明明是异步操作,怎么保证"不漏单、不重单"。下面分别讨论。
桌台二维码:可校验比"能扫"重要得多
桌台二维码的工程本质不是"把桌号编码进去",而是让服务端在收到扫码请求时,能够可信地判定这张码对应哪家门店的哪张桌台。
一种常见的简化做法是二维码直接放 tableId=18。这种设计的问题是显而易见的:用户把 URL 里的数字改成 19,服务端如果不做额外校验,就会按 19 号桌创建订单,跨桌下单在技术上没有任何门槛。改进方案通常有两种。第一种是在二维码参数中加入服务端可验证的签名,例如 tableId=18&shopId=6&sig=abc,sig 由服务端用 tableId + shopId + secret 计算,扫码后先验签再建单。第二种更彻底,二维码只放一个无业务含义的短码,服务端查表反查出门店和桌号,短码本身不携带可篡改的业务语义。
无论选哪种,桌台表的设计都要先做对。store_id 是必须字段,连锁场景下不同门店的桌号允许重复,但查询、验签、建单都必须带上门店条件,否则一定会出现跨店串单:
sql
CREATE TABLE store_table (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
store_id BIGINT NOT NULL,
table_name VARCHAR(100),
table_code VARCHAR(64) NOT NULL,
status TINYINT DEFAULT 0, -- 0 空闲 / 1 使用中
capacity INT,
UNIQUE KEY uk_store_code (store_id, table_code)
);
另一个容易被忽视的点是桌台状态的并发控制。高峰期两个顾客同时扫同一张桌子,如果两个请求都执行"把状态从空闲改为使用中",后到的请求应该被拒绝,而不是覆盖。用条件更新即可解决,不需要引入分布式锁:
sql
UPDATE store_table
SET status = 1
WHERE id = ? AND status = 0;
影响行数为 0,说明桌台已被占用,接口直接返回明确的业务提示,而不是把并发冲突当成系统异常抛出。这个细节看起来很小,但它决定了高峰期"重扫"行为会不会制造出一桌多单的脏数据。
堂食订单状态机:状态推进本身就是业务动作
扫码点餐的订单生命周期和普通商城订单不同。商城订单的核心状态是"待支付 → 已支付 → 已发货 → 已完成",堂食订单则需要覆盖从顾客坐下到桌台释放的完整过程,典型状态包括:
待确认 → 已确认 → 制作中 → 已出餐 → 已完成
状态机设计的真正难点不在于字段定义,而在于每一次状态迁移都必须绑定的副作用 。如果只是更新 status 字段,而不同时触发对应的业务动作,状态机就退化成一个装饰性的枚举。
| 状态迁移 | 必须触发的动作 |
|---|---|
| 待确认 → 已确认 | 订单进入门店处理队列,开始接单计时 |
| 已确认 → 制作中 | 生成厨房打印任务,后厨进入制作环节 |
| 制作中 → 已出餐 | 通知取餐或叫号,出餐标记写入 |
| 已出餐 → 已完成 | 释放桌台资源,桌台回到空闲状态 |
这里有一个工程约束值得单独强调:状态迁移必须收敛到唯一入口,禁止在任何业务代码中直接 update 订单状态字段。一旦某个加菜逻辑、退款逻辑或外部回调绕过状态机直接改库,就会出现"订单显示已出餐但打印没触发""桌台显示空闲但订单还没完成"之类的数据不一致,而且这类问题在日志里极难追溯。一个可行的做法是让所有状态变更走一个统一的方法,在方法内部完成合法性校验、条件更新和副作用派发:
java
// 伪代码:状态机统一入口
boolean transit(Long orderId, OrderStatus from, OrderStatus to) {
// 条件更新保证并发安全
int affected = orderMapper.updateStatus(orderId, to, from);
if (affected == 0) {
throw new IllegalStateException("状态已变更或非法迁移");
}
// 副作用在状态确认迁移成功后派发
if (to == OrderStatus.COOKING) {
printTaskService.enqueueKitchenTicket(orderId);
}
if (to == OrderStatus.FINISHED) {
tableService.releaseByOrder(orderId);
}
return true;
}
先条件更新、成功后再派发副作用,顺序不能反过来。如果先发打印再改状态,打印触发成功但状态更新失败时,补打和重试的边界会变得非常混乱。
厨房打印:异步不是"不管",ACK 才是成功判据
厨房打印最容易踩的坑,是把"调用打印接口返回 200"当成"打印机已经出纸"。真实的链路是:业务服务生成打印任务 → 推给打印网关 → 网关转发给网口打印机或云打印 → 打印机走纸、切刀,最后回一个 ACK。中间任何一跳出现网络抖动、打印机缺纸、WiFi 断连,HTTP 200 都可能已经返回,但纸并没有出来。
因此,打印不能采用"即发即忘"的调用方式,而要把每一次出纸当成一条有状态、可重试、可幂等的任务来处理。任务表至少需要业务单号、打印机标识、内容指纹、状态、重试次数和下次触发时间:
sql
CREATE TABLE print_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_no VARCHAR(40) NOT NULL, -- 幂等键
store_id BIGINT NOT NULL,
printer_id BIGINT NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(12) DEFAULT 'PENDING', -- PENDING / SENT / DONE / FAILED
retry_count INT DEFAULT 0,
max_retry INT DEFAULT 5,
next_fire_at DATETIME NOT NULL,
ack_at DATETIME NULL,
UNIQUE KEY uk_task_no (task_no)
);
状态推进的规则是:任务落库为 PENDING;投递器发出后置为 SENT;只有收到打印机回传的 ACK 才置为 DONE 。SENT 不是终态,发出但未确认的任务仍然要被轮询追问,超时后按退避策略重新投递。
幂等同样关键。同一张订单的厨房小票,无论因为网络重试被投递了多少次,同一台打印机只能产生一条有效任务。UNIQUE KEY uk_task_no 加上业务单号与打印机的组合唯一约束,是防止重复出纸的第一道闸。没有这个约束,"超时重发"很容易变成"后厨同时收到两张一样的单",在高峰期反而制造混乱。
还有一个部署层面的约束:打印机必须绑定到具体的 store_id。连锁场景下如果打印任务只按 printer_id 路由,A 店订单被误发到 B 店打印机,后厨对着一份不属于自己的小票出餐,比漏单更难排查。
几个容易被当成"小问题"的配置点
桌台、状态机和打印这三块主体逻辑之外,有几个配置项的优先级经常被低估,但它们在真实门店里会反复触发故障:
桌台释放时机。 如果系统设计为"订单完成自动释放",那么必须确认完成动作确实由结账或核销触发,而不是只依赖服务员手动改状态。否则桌台长期停在"使用中",后续顾客扫码会被拒绝,现场只能反复重扫或找服务员后台操作。
菜品变更后的缓存失效。 菜单数据通常走缓存,菜品下架或沽清后,如果对应门店的菜单缓存没有及时清除,顾客扫码看到的是旧菜单,提交订单时才会在服务端报错。这类错误发生在下单环节,顾客感知很强,但根因往往只是一个缓存 key 没有在状态变更时被主动删除。
打印失败的可观测性。 FAILED 状态的任务必须能在后台被看到,并提供补打入口。后厨忙的时候,打印机缺纸、卡纸是高频事件,如果系统只记录"打印失败"而没有呈现给门店人员,漏单就只能靠口头发现。
扫码点餐系统的技术难度,不在"扫码"这个动作本身,而在扫码之后建立起来的那条约束链:桌台绑定必须可校验、状态迁移必须带副作用、打印投递必须以硬件 ACK 为终态。把这三件事设计清楚,比在业务层堆叠功能更重要;反过来,如果这三条链路里任何一条没有收敛到可靠的设计上,系统功能再多,高峰期也一定会以漏单、串单或桌台状态错乱的形式暴露出来。