微信扫码→下单→支付→出票,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
}
回调验证的五个检查点:
- 签名验证——确认回调来自微信/支付宝,不是伪造的
- 幂等处理——同一笔回调可能收到多次,只处理一次
- 订单查询——确认订单存在
- 金额验证——回调金额必须与订单金额一致
- 状态更新——只有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后端架构全景
- 从单体到多租户:餐饮SaaS架构演进之路
- 100家店的数据如何互不偷看:多租户隔离深度解析
- 你的支付密钥我们连自己都看不到:AES-256-GCM加密实战
- 3秒出单200ms响应:餐饮收银系统性能密码
- 从1家店到100家店代码一行不用改:多门店架构设计
- 退款5年可追溯:餐饮SaaS合规体系构建
- 微信扫码→下单→支付→出票,30秒全链路
- Go + PostgreSQL + Redis:餐饮SaaS后端架构全景
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 jaytp@qq.com