餐厅 SaaS 平台如何确保只有店内顾客可以下单?
建议由员工在顾客到店后开台授权,创建限时点餐会话,再由服务端逐单校验。如果餐厅要求顾客连接店内网络才能下单,再加上网络准入限制。
仅靠 GPS、IP 地址或桌面二维码,都无法严格证明用户此刻就在餐厅内。二维码可以被拍照转发,定位数据可以被伪造,店内 Wi-Fi 也可能覆盖到门外。设计前要先明确:是要阻止未经到店授权的人下单,还是要求顾客离店后立即停止下单?后者需要更强的网络约束,但仍受信号覆盖范围和请求转发的限制。
常见方法能解决什么问题?
| 方法 | 适合的用途 | 主要局限 |
|---|---|---|
| 桌面固定二维码 | 打开菜单、识别餐桌 | 拍照后可长期在店外使用,不能单独作为下单凭证 |
| 动态二维码或短时口令 | 限制旧照片、旧链接被重复使用 | 有效期内仍可被转发 |
| 浏览器定位与地理围栏 | 辅助判断用户是否在门店附近 | 室内定位存在误差,需要用户授权,也无法可靠区分店内与门外 |
| 店内 Wi-Fi 或门店网关 | 限制请求经过指定网络 | 增加连接步骤,覆盖范围可能超出门店 |
| 服务员开台并发放凭证 | 将下单权限绑定到一次实际接待 | 需要员工参与,凭证仍可能被顾客分享 |
普通堂食业务可以优先采用服务员开台和限时会话。顾客可以使用移动网络点餐,餐厅也方便在结账后统一撤销权限。
推荐的实现流程
1. 为每次用餐创建独立记录
顾客入座后,员工在后台开台,为本次用餐创建独立记录,生成 visit_id,并关联以下信息:
- 所属租户或商户;
- 门店与餐桌;
- 开台状态;
- 本次用餐的有效时间。
餐桌编号长期不变,每次用餐都应生成新的 visit_id,避免上一桌顾客保存的链接影响下一桌。
2. 发放短时入场凭证
员工通过小票、手持设备或桌面屏幕,向顾客展示本次用餐的二维码。二维码中包含服务端生成的高熵随机凭证,服务端保存凭证的哈希及其关联的 visit_id。
顾客成功兑换凭证后,服务端创建点餐会话。入场凭证可以设为一次性使用,同桌多人分别领取凭证,或通过受控流程加入同一次用餐。
凭证有效期应根据实际接待流程设置。例如,可以先试行“两分钟内兑换”,再根据现场体验调整。两分钟只是配置示例,具体时长需要结合门店流程确定。如果使用便于手动输入的短数字口令,还必须限制猜测次数和兑换频率。
固定桌码可以保留,用于打开菜单或申请加入餐桌,下单则需要额外授权。
3. 每次下单都检查会话和开台状态
在前端隐藏“提交订单”按钮,无法阻止用户直接调用 API。服务端处理每笔订单时,都需要检查:
- 点餐会话存在、未过期且未撤销;
- 对应的用餐记录仍处于开台状态;
- 会话、门店和餐桌属于同一授权范围;
- 菜品可在该门店销售,数量符合规则,价格由服务端计算。
结账、换桌或取消开台时,要及时更新授权状态。“结账关闭”和“提交订单”并发执行时,也必须保证系统不会在关闭后继续接受订单。
4. 按需要增加网络校验
如果要求每笔订单都来自店内网络,可以选择:
- 在云端仅允许指定门店的出口 IP 提交订单,前提是出口地址稳定且可维护;
- 让下单请求经过门店网关,由云端验证网关身份和请求完整性。
两种方式都要求公开 API 执行相同的门店策略,否则用户仍可绕过门店入口,直接向云端发送请求。
服务端不能直接信任客户端提交的 X-Forwarded-For、门店编号或“已连接 Wi-Fi”标志。代理头应由受信任的代理处理,网关身份也必须经过认证。
服务端校验示例
以下伪代码接近 Python 语法,用于说明授权检查应放在哪里。db 和辅助函数均为业务接口示意,不对应具体框架。
假设顾客已兑换入场凭证,浏览器通过 Cookie 携带随机会话令牌。Cookie 设置了 Secure、HttpOnly 和适当的 SameSite 属性,数据库只保存令牌哈希。
def submit_order(request):
token = request.cookies.get("order_session")
if not token:
return error(401, "请先获取本次用餐的下单权限")
# Cookie 认证还需要按应用部署方式落实 CSRF 防护。
require_csrf_protection(request)
require_rate_limit(request)
with db.transaction() as tx:
# 关联并锁定会话和本次用餐记录。
# 结账关闭流程必须遵循相同的锁定规则。
context = tx.lock_order_context(hash_token(token))
if context is None:
return error(401, "点餐会话无效")
now = tx.current_time()
if context.session_revoked or context.session_expires_at <= now:
return error(403, "点餐会话已失效")
if context.visit_status != "open" or context.visit_expires_at <= now:
return error(403, "本次用餐已结束或超时")
# 身份范围从服务端会话取得,不接受客户端任意指定餐桌。
tenant_id = context.tenant_id
restaurant_id = context.restaurant_id
table_id = context.table_id
visit_id = context.visit_id
if context.require_store_network:
require_trusted_store_network(request, restaurant_id)
# 校验菜品归属、可售状态和数量,并使用服务端价格。
items = validate_and_price_items(
tx, restaurant_id, request.json["items"]
)
# 幂等键必须限定在当前授权范围内。
# 相同键和相同请求返回原订单;相同键但内容不同则拒绝。
order = tx.create_order_idempotently(
tenant_id=tenant_id,
restaurant_id=restaurant_id,
table_id=table_id,
visit_id=visit_id,
items=items,
idempotency_key=require_idempotency_key(request),
)
return success(order)
兑换入场凭证时,也要在事务或原子操作中完成检查未使用、检查未过期、标记已使用和创建会话。否则,同一个二维码可能被并发兑换多次。订单幂等也需要数据库唯一约束等机制支持,仅在应用层先查后写无法保证幂等。
上线时需要注意的边界
已获授权的顾客离店后,仍可能使用尚未失效的会话。只在入场时验证一次,无法满足“每笔订单提交时都必须在店内”的要求。可以缩短会话有效期,在结账时立即撤销权限,并按需要逐单验证门店网络。
动态二维码能缩短转发链接的可用时间,但店内的人仍可实时分享凭证。给二维码添加数字签名可以防止内容被篡改,无法防止复制和重放。
位置异常适合作为辅助判断信号。顾客拒绝定位、定位发生漂移或无法连接 Wi-Fi 时,应提供由员工确认的处理入口。采集位置数据时,要控制采集范围和保存时间,避免为了点餐持续跟踪顾客。
如果主要担心恶意订单进入后厨,可以要求首次订单、大额订单或下单频率异常的订单等待员工确认,同时为餐桌设置合理的订单数量限制。先付款也能降低部分损失,但支付成功本身无法证明付款人在店内。
普通堂食平台可以先实施员工开台、短时凭证兑换、逐单检查和结账撤销,再根据实际滥用情况增加网络限制。如果必须严格控制网络来源,就需要门店网络或网关配合,同时接受覆盖范围和请求转发风险带来的限制。
备注:内容仅供参考。
