微信扫码→下单→支付→出票,30秒全链路

微信扫码→下单→支付→出票,30秒全链路

餐饮收银最核心的场景,就是顾客掏出手机扫码付款。从收银员输入金额、顾客扫码,到系统确认支付、打印小票,整个链路必须在30秒内完成。

这30秒里,系统做了什么?这篇我们从头到尾拆解这条链路。

全链路概览

收银员输入金额 → 生成订单 → 顾客扫码 → authCode传入
    → 调用SDK.Pay() → 返回USERPAYING → 轮询等待
    → 用户确认支付 → 支付成功回调 → 验证签名
    → 更新订单状态 → 打印小票 → 完成

整条链路涉及5个系统:收银终端、龙讯后端、微信/支付宝平台、支付回调、小票打印机。

第一步:生成订单

收银员在终端输入菜品和金额,系统生成订单:

func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    tenantID := ctx.Value(CtxKeyTenantID).(int64)
    storeID := ctx.Value(CtxKeyStoreID).(int64)

    // 生成订单号(Redis原子日流水号)
    orderNo, err := s.GenerateOrderNo(ctx, tenantID, storeID)
    if err != nil {
        return nil, err
    }

    // 计算总金额(Decimal,元)
    totalAmount := s.calculateTotal(req.Items)

    order := &Order{
        TenantID:       tenantID,
        StoreID:        storeID,
        OrderNo:        orderNo,
        TotalAmount:    totalAmount,
        OriginalAmount: totalAmount,
        PaymentStatus:  "pending",
        Status:         "pending",
        OrderType:      "normal",
        IsHidden:       false,
        Items:          req.Items,
        CreatedAt:      time.Now(),
    }

    // 幂等键防重复
    idempotencyKey := s.generateIdempotencyKey(req)
    ok, _ := s.rdb.SetNX(ctx, fmt.Sprintf("idem:%s", idempotencyKey), 1, 30*time.Second).Result()
    if !ok {
        return s.findOrderByIdempotencyKey(ctx, idempotencyKey)
    }

    if err := s.db.Create(order).Error; err != nil {
        return nil, err
    }

    return order, nil
}

第二步:顾客扫码,authCode传入

顾客用微信/支付宝扫描收银台的付款码,收银终端获取到授权码(authCode)——这是一串18位数字,代表顾客的付款账户。

type ScanPayRequest struct {
    OrderNo   string // 订单号
    AuthCode  string // 付款码(18位数字)
    Channel   string // wechat / alipay
}

authCode有时效性——微信的authCode有效期为1分钟,支付宝为5分钟。超过时效需要顾客重新扫码。

第三步:调用SDK.Pay()

拿到authCode后,我们调用支付SDK的付款接口:

func (s *PaymentService) ScanPay(ctx context.Context, req *ScanPayRequest) (*PayResponse, error) {
    // 1. 查询订单
    var order Order
    if err := s.db.Where("order_no = ?", req.OrderNo).First(&order).Error; err != nil {
        return nil, ErrOrderNotFound
    }

    // 2. 查询商户支付配置
    config, err := s.getPaymentConfig(ctx, order.TenantID, req.Channel)
    if err != nil {
        return nil, err
    }

    // 3. 解密API密钥
    apiKey, err := s.encryptor.Decrypt(config.EncryptedApiKey, config.KeyVersion)
    if err != nil {
        return nil, err
    }

    // 4. 调用支付SDK
    payReq := &sdk.PayRequest{
        MchID:    config.MchID,
        ApiKey:   apiKey,
        OrderNo:  order.OrderNo,
        Amount:   order.TotalAmount,
        AuthCode: req.AuthCode,
        Body:     "餐饮消费",
    }

    resp, err := s.paymentClient.Pay(ctx, payReq)
    if err != nil {
        return nil, err
    }

    // 5. 处理支付结果
    switch resp.TradeState {
    case "SUCCESS":
        // 直接成功
        s.handlePaymentSuccess(ctx, &order, resp)
        return &PayResponse{Status: "SUCCESS", TradeNo: resp.TradeNo}, nil

    case "USERPAYING":
        // 用户正在输入密码,需要轮询
        return &PayResponse{Status: "USERPAYING", TradeNo: resp.TradeNo}, nil

    default:
        // 支付失败
        return &PayResponse{Status: "FAILED"}, nil
    }
}

第四步:USERPAYING轮询

这是扫码支付最关键的环节。当顾客需要输入支付密码时,微信/支付宝会返回USERPAYING状态。此时系统需要轮询查询支付结果,直到用户完成支付或超时。

func (s *PaymentService) PollPaymentResult(ctx context.Context, orderNo string, tradeNo string) (*PayResponse, error) {
    maxRetries := 30         // 最多轮询30次
    interval := 3 * time.Second // 每次间隔3秒
    totalTimeout := 90 * time.Second // 总超时90秒

    deadline := time.Now().Add(totalTimeout)

    for i := 0; i < maxRetries && time.Now().Before(deadline); i++ {
        time.Sleep(interval)

        // 查询支付结果
        resp, err := s.paymentClient.Query(ctx, &sdk.QueryRequest{
            OrderNo: orderNo,
            TradeNo: tradeNo,
        })
        if err != nil {
            log.Printf("query payment error: %v", err)
            continue
        }

        switch resp.TradeState {
        case "SUCCESS":
            var order Order
            s.db.Where("order_no = ?", orderNo).First(&order)
            s.handlePaymentSuccess(ctx, &order, resp)
            return &PayResponse{Status: "SUCCESS", TradeNo: resp.TradeNo}, nil

        case "USERPAYING":
            // 继续轮询
            continue

        case "CLOSED", "PAYERROR":
            return &PayResponse{Status: "FAILED"}, nil

        case "NOTPAY":
            // 还没支付,继续等
            continue
        }
    }

    // 超时,撤销支付
    s.paymentClient.Reverse(ctx, &sdk.ReverseRequest{OrderNo: orderNo})
    return &PayResponse{Status: "TIMEOUT"}, nil
}

轮询策略:

参数 原因
轮询间隔 3秒 微信建议5秒,我们优化到3秒
最大次数 30次 3秒×30次=90秒,覆盖大部分场景
总超时 90秒 超过后撤销支付,避免单子挂着
超时处理 撤销(Reverse) 释放商户的收款额度

为什么不用WebSocket?

有人可能会问:轮询这么低效,为什么不用WebSocket推送?

原因是微信/支付宝的支付结果是通过回调(Callback)通知的,不是实时推送。回调可能有延迟(通常1-5秒),在回调到达之前,只能靠轮询。我们的方案是轮询+回调双保险

  • 轮询保证实时性(3秒一次)
  • 回调保证可靠性(即使轮询漏了,回调也会通知)

第五步:支付回调验证

微信/支付宝的支付回调需要严格验证,防止伪造通知:

func (s *PaymentService) HandleCallback(ctx context.Context, channel string, body []byte, signature string) error {
    // 1. 验证签名
    config, _ := s.getPaymentConfigByChannel(ctx, channel)
    apiKey, _ := s.encryptor.Decrypt(config.EncryptedApiKey, config.KeyVersion)

    if !s.paymentClient.VerifySignature(body, signature, apiKey) {
        return ErrInvalidSignature
    }

    // 2. 解析回调数据
    callback, err := s.paymentClient.ParseCallback(body)
    if err != nil {
        return err
    }

    // 3. 幂等处理:防止重复回调
    callbackKey := fmt.Sprintf("callback:%s:%s", channel, callback.TradeNo)
    ok, _ := s.rdb.SetNX(ctx, callbackKey, 1, 24*time.Hour).Result()
    if !ok {
        return nil // 已处理过,直接返回
    }

    // 4. 查询订单
    var order Order
    if err := s.db.Where("order_no = ?", callback.OrderNo).First(&order).Error; err != nil {
        return err
    }

    // 5. 验证金额
    if !order.TotalAmount.Equal(callback.TotalAmount) {
        log.Printf("金额不匹配:订单 %s,回调 %s", order.TotalAmount.String(), callback.TotalAmount.String())
        return ErrAmountMismatch
    }

    // 6. 更新订单状态
    if callback.TradeState == "SUCCESS" {
        s.handlePaymentSuccess(ctx, &order, callback)
    }

    return nil
}

回调验证的五个检查点:

  1. 签名验证——确认回调来自微信/支付宝,不是伪造的
  2. 幂等处理——同一笔回调可能收到多次,只处理一次
  3. 订单查询——确认订单存在
  4. 金额验证——回调金额必须与订单金额一致
  5. 状态更新——只有pending状态的订单才更新

第六步:打印小票

支付成功后,触发小票打印:

func (s *OrderService) handlePaymentSuccess(ctx context.Context, order *Order, resp PaymentResult) {
    // 更新订单状态
    order.PaymentStatus = "paid"
    order.Status = "paid"
    order.PaidAt = time.Now()
    s.db.Save(order)

    // 记录支付日志
    s.paymentLog.Log(ctx, order, resp)

    // 触发小票打印(异步)
    go s.printer.PrintReceipt(ctx, order)
}

func (p *PrinterService) PrintReceipt(ctx context.Context, order *Order) error {
    receipt := &Receipt{
        StoreName:  p.getStoreName(order.StoreID),
        OrderNo:    order.OrderNo,
        Items:      order.Items,
        Amount:     order.TotalAmount,
        PaidAt:     order.PaidAt,
        TradeNo:    order.TradeNo,
        Footer:     "感谢惠顾,欢迎再次光临!",
    }

    // 发送到小票打印机
    return p.sendToPrinter(order.StoreID, receipt)
}

小票打印是异步的——不影响支付流程的响应时间。即使打印机暂时离线,订单状态也已经正确更新,收银员可以继续服务下一位顾客。

混合支付:现金+电子支付

餐饮场景中,混合支付很常见:顾客用微信付了50元,剩下30元用现金。我们的方案:

type MixedPayment struct {
    OrderNo     string
    TotalAmount Decimal // 订单总金额(元)
    Payments    []PaymentItem
}

type PaymentItem struct {
    Channel string // wechat / alipay / cash / unionpay
    Amount  Decimal // 本渠道支付金额(元)
    TradeNo string // 电子支付的交易号
}

func (s *PaymentService) MixedPay(ctx context.Context, req *MixedPayment) (*PayResponse, error) {
    // 1. 验证总金额
    totalPaid := decimal.Zero
    for _, item := range req.Payments {
        totalPaid = totalPaid.Add(item.Amount)
    }
    if !totalPaid.Equal(req.TotalAmount) {
        return nil, ErrAmountMismatch
    }

    // 2. 逐个渠道处理
    for _, item := range req.Payments {
        switch item.Channel {
        case "cash":
            // 现金支付,直接记录
            s.recordCashPayment(ctx, req.OrderNo, item.Amount)
        case "wechat", "alipay":
            // 电子支付,走正常支付流程
            s.recordElectronicPayment(ctx, req.OrderNo, item)
        }
    }

    // 3. 更新订单状态
    s.db.Model(&Order{}).Where("order_no = ?", req.OrderNo).
        Updates(map[string]interface{}{
            "status":  "paid",
            "paid_at": time.Now(),
        })

    return &PayResponse{Status: "SUCCESS"}, nil
}

混合支付的关键:各渠道金额之和必须等于订单总金额,否则拒绝支付。

团购核销:美团/抖音券码验证

除了扫码支付,餐饮场景还有团购核销——顾客在美团/抖音买了团购券,到店出示券码,收银员核销后出餐。

type CouponVerifyRequest struct {
    TenantID  int64
    StoreID   int64
    CouponCode string // 团购券码
    Platform  string // meituan / douyin
}

func (s *CouponService) Verify(ctx context.Context, req *CouponVerifyRequest) (*CouponInfo, error) {
    // 调用对应平台的核销SDK
    var result *CouponInfo
    var err error

    switch req.Platform {
    case "meituan":
        result, err = s.meituanSDK.Verify(ctx, req.CouponCode)
    case "douyin":
        result, err = s.douyinSDK.Verify(ctx, req.CouponCode)
    default:
        return nil, ErrUnsupportedPlatform
    }

    if err != nil {
        return nil, err
    }

    // 记录核销日志
    s.auditLog.Log(ctx, "coupon_verify", "coupon", result.CouponID, req.CouponCode)

    return result, nil
}

func (s *CouponService) Consume(ctx context.Context, req *CouponVerifyRequest) error {
    // 先验证券码有效性
    info, err := s.Verify(ctx, req)
    if err != nil {
        return err
    }
    if info.Status != "valid" {
        return ErrCouponAlreadyUsed
    }

    // 调用平台SDK核销
    switch req.Platform {
    case "meituan":
        return s.meituanSDK.Consume(ctx, req.CouponCode)
    case "douyin":
        return s.douyinSDK.Consume(ctx, req.CouponCode)
    }

    // 记录核销
    s.auditLog.Log(ctx, "coupon_consume", "coupon", info.CouponID, req.CouponCode)

    return nil
}

团购核销和扫码支付是独立的流程,但可以组合使用:顾客用团购券抵扣部分金额,剩余金额扫码支付。

全链路时序图

收银终端          龙讯后端           微信/支付宝         回调服务
   |                |                  |                |
   |--创建订单----->|                  |                |
   |<--订单号-------|                  |                |
   |                |                  |                |
   |--扫码支付----->|--SDK.Pay()------>|                |
   |                |<--USERPAYING----|                |
   |<--轮询中-------|                  |                |
   |                |                  |                |
   |                |--Query()-------->|                |
   |                |<--USERPAYING----|                |
   |                |                  |                |
   |                |                  |--支付成功回调-->|
   |                |<--回调验证-------|                |
   |                |                  |                |
   |                |--Query()-------->|                |
   |                |<--SUCCESS-------|                |
   |<--支付成功-----|                  |                |
   |                |                  |                |
   |--打印小票----->|                  |                |
   |                |                  |                |

30秒分解:

阶段 耗时 累计
创建订单 50ms 0.05s
扫码+SDK.Pay() 2s 2.05s
用户输入密码 5-15s 7-17s
轮询确认 3-6s 10-23s
回调验证+状态更新 100ms 23.1s
打印小票 2-3s 26.1s

最慢的场景(用户输密码慢+轮询次数多)约26秒,在30秒目标之内。

小结

扫码支付全链路的关键设计:

设计点 方案 价值
幂等键 Redis SETNX 防重复下单
authCode时效 1分钟内完成支付 避免过期码
USERPAYING轮询 3秒间隔,90秒超时 覆盖密码支付场景
回调验证 签名+幂等+金额校验 防伪造回调
异步打印 goroutine异步 不阻塞支付流程
混合支付 多渠道金额校验 支持现金+电子组合
团购核销 美团/抖音SDK 支持团购券场景

下一篇,也是最后一篇,我们做后端架构的全景回顾——Go + PostgreSQL + Redis,15个中间件、Repository模式、Service层、decimal.Decimal元精度、分布式锁、幂等键,一个不落。


系列导航:[龙讯餐饮SaaS架构实战]

← 上一篇:退款5年可追溯:餐饮SaaS合规体系构建 | 下一篇 →Go + PostgreSQL + Redis:餐饮SaaS后端架构全景

  1. 从单体到多租户:餐饮SaaS架构演进之路
  2. 100家店的数据如何互不偷看:多租户隔离深度解析
  3. 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
  4. 3秒出单200ms响应:餐饮收银系统性能密码
  5. 从1家店到100家店代码一行不用改:多门店架构设计
  6. 退款5年可追溯:餐饮SaaS合规体系构建
  7. 微信扫码→下单→支付→出票,30秒全链路
  8. Go + PostgreSQL + Redis:餐饮SaaS后端架构全景