PAYATHON 2026

餐厅 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 时,应提供由员工确认的处理入口。采集位置数据时,要控制采集范围和保存时间,避免为了点餐持续跟踪顾客。

如果主要担心恶意订单进入后厨,可以要求首次订单、大额订单或下单频率异常的订单等待员工确认,同时为餐桌设置合理的订单数量限制。先付款也能降低部分损失,但支付成功本身无法证明付款人在店内。

普通堂食平台可以先实施员工开台、短时凭证兑换、逐单检查和结账撤销,再根据实际滥用情况增加网络限制。如果必须严格控制网络来源,就需要门店网络或网关配合,同时接受覆盖范围和请求转发风险带来的限制。

备注:内容仅供参考。