2.3 到店核销:扫码与状态回写

场景:到店之后怎么证明来过

预约成功只是开始,服务行业的闭环在到店那一刻。林姐最初的想法特别原始:客人到店报手机号,前台在小程序后台查到预约单,点一下确认。这个方案第一天就翻车了:店里音乐声大、信号差,前台查单要二十秒,高峰期排队的客人越等越不耐烦。更尴尬的是报错手机号的客人,前台查不到单,双方都以为是对方搞错了。

我们必须让核销这个动作在三秒内完成,而且不依赖信号。方案是小程序里生成一张预约二维码,门店收银台贴一张固定二维码,两个码互相扫。客人流量差的时候扫店里的码,网络好的时候店扫客人的码,双向都能走通。扫码这个动作对美甲店来说还有额外好处:客人主动打开小程序的那一刻,就是下一次复购的起点。核销成功页我们顺手放了一张会员码,约四成核销客人会顺手点开会员页,这个转化率比任何推送都高。

方案:双向扫码与状态回写

之所以坚持双向,是因为我们对客人手机的信号做了次小调查:商场店地下一层经常没 4G,但店里的工作机走的是店里 WiFi。双向方案把命运掌握在了两套网络里,任何一个能通就能核销。这个设计后来在商场店救场无数次,前台小姐姐对此赞不绝口,专门给我们发了锦旗截图,虽然锦旗是表情包。

方案:双向扫码与状态回写

核销的本质是把预约单从已预约推进到已核销,同时写入一条核销流水。t_verify 表只管记录谁在什么时间核了哪一单,状态变更仍然回到 t_booking 上做,保持单一状态源。这个设计决定了核销和预约共享同一个状态机,不会出现两套状态互相打架的局面。

为什么二维码扫的是核销码而不是 book_id 明文?因为明文可枚举,隔壁店使坏扫一圈就能把别家单子核掉。HMAC 签名让伪造核销码的成本高到不值得,安全合规性和客人的信任都保住了,被林姐评为本项目最不像小团队会有的设计。

核销方式 客人操作 前台操作 适用场景
客人扫门店码 打开扫一扫 等待推送 客人手机信号好
前台扫客人码 出示二维码 用工作机扫 店内设备稳定
手动兜底 报手机号 后台搜索核销 双方都没电

二维码的内容不是随便编的,我们用的是带签名的一串短码,防止有心人伪造预约:核销码 = book_id 加 时间戳 加 HMAC 签名,扫码后后端验签再推进状态。核销码一次性有效,核过即作废,同一张码扫第二下只会提示已使用。客人拿昨天的码来扫也一样,前台看到提示就知道去后台查历史单据。

整条核销链路的时间预算我们定得死死的,超过就要优化:

环节 目标耗时 实测耗时
打开小程序出码 1 秒 0.8 秒
扫码识别 1 秒 0.5 秒
后端验签推进 0.5 秒 0.2 秒
前台收到播报 0.5 秒 0.3 秒

加起来两秒内,客人从进门到坐下不用再说一句废话。前台小姐姐的评价是:比原来报手机号快了十倍不止,最关键的是再也不用扯着嗓子问您约的几点。

配置/代码:核销接口的核心

核销接口只接收一个 code 参数,所有信息都从这串码反解出来,前后端之间来回传的永远只有这一串:

func verifyBooking(c *gin.Context) {
    code := c.Query("code")
    bookID, ts, ok := decodeHMAC(code, secret)
    if !ok || time.Since(ts) > 30*time.Minute {
        c.JSON(200, gin.H{"code": 1, "msg": "核销码无效"})
        return
    }
    // 推进状态并写流水
}

30 分钟有效期是故意压短的,核销本身就是当场行为,码的有效期越短被截图盗用的风险越低。客人出门再想扫已经作废,也给隐私留了余地:码里不含任何手机号或姓名,就算截图发朋友圈也不会泄露信息。客人对这一点的安心感,林姐说比什么会员协议都管用。

状态推进同样走原子更新,防止前台手滑扫了两次,也防止手快的客人在两个前台同时扫码:

UPDATE t_booking SET status=2
WHERE book_id=? AND status=1;

只有状态为 1 的预约能被核销,已取消或已超时的单会自然挡掉。核销流水另写一张表,记下 staff_id 与核销时间,后面给美甲师算提成全靠它。流水的结构简单到只有五个字段,查询方便归档也方便:

CREATE TABLE t_verify (
  verify_id BIGINT PRIMARY KEY AUTO_INCREMENT,
  book_id   BIGINT NOT NULL,
  staff_id  INT NOT NULL,
  verify_at DATETIME NOT NULL,
  channel   TINYINT NOT NULL  -- 1 客人扫 2 前台扫 3 手动
);

channel 字段是后期加上去的,起初我们只记谁核了,不记通过什么渠道,后来发现渠道数据对评估扫码推广效果很有用,才追加了这一列。事实证明 channel 2(前台扫客人)占比 71%,客人扫店家的只占 23%,剩下的手动兜底——这个分布让我们决定把工作机扫码体验打磨到极致。

二维码图片本身的生成放在 COS 上:服务端生成图片后传到对象存储,小程序直接用图片地址展示,不占用服务器带宽,客人打开速度也快。如果二维码直接由服务端返回 base64,每次打开小程序都要走一遍接口渲染, COS 方案把这个开销彻底甩掉了。上传一段示意:

func uploadQR(ctx context.Context, bookID int64, png []byte) (string, error) {
    key := fmt.Sprintf("qr/%d.png", bookID)
    _, err := cosClient.Object.Put(ctx, key, bytes.NewReader(png), nil)
    return key, err
}

数据闭环:核销之后发生什么

核销完成只是表面动作,后面的连锁反应才是店务管理的燃料。状态推进成功后,系统会同步做三件事:给客人发一条感谢订阅消息、把服务时长记入员工工时、在转化漏斗里把这一单点实。每件事都挂在事件总线上异步执行,不阻塞核销接口本身,前台按核销按钮的瞬间就能收到成功回应。

核销后动作 落点 用途
感谢提醒 订阅消息 引导评价复购
员工工时 t_staff_daily 算提成排班
漏斗计数 看板缓存 店长看转化

三件事彼此独立,任何一件失败都不影响核销结果,重试机制保证最终一致。这套事件总线用 Go channel 加协程实现,没有引入外部消息队列,简单够用,日核销量两百单以内毫无压力。

踩坑:四个核销现场的坑

第一个坑是二维码刷新。预约成功页的二维码我们最初只生成一次,客人切换到后台再回来,图片缓存没了要重新拉,弱网下转圈转半天。后来把码的有效期提到 30 分钟并支持本地刷新,弱网下也能保证随时可扫。

第二个坑是时区错位导致的核销失败。核销码里带的时间戳用过 UTC,而判定用的是服务器本地时间,偶发性的验签失败让前台莫名被客人吐槽,一天碰上三四单就要怀疑系统是不是坏了。统一成 Unix 时间戳后问题消失,这件事提醒我们任何时间比较都要明确基准。

第三个坑是手动核销没有留痕。前台图省事直接改数据库的状态,美甲师提成对不上,查起来一头雾水。强制所有核销必须走接口写流水,哪怕是手动兜底也要在后台点按钮,任何一个都不能省。流水表后来成了店里解决纠纷的唯一凭据,客人说没核、前台说核了,一查 t_verify 立刻定案。

第四个坑是预约时段已过的核销。客人约了上午 10:00,下午 3 点才来,前台一扫提示已过期无法核销,双方大眼瞪小眼。我们补了一条店长权限的改期核销,允许把单迁到当前实际时段后再核,既保住数据又保住客人面子。这条补丁上线后,核销成功率从 97% 涨到 99.6%,剩下的 0.4% 是真没来的。

小结

核销环节看似简单,却是服务信任的最后一公里。双向扫码加 HMAC 防伪加状态机约束,三件套保证了三秒完成、不可伪造、留痕可查。林姐后来最常夸的就是这一环:前台再也没排队,客人觉得这家店科技感十足,本来前台核销最怕客人在旁边等,现在三秒搞定,整个店铺的运转节奏都快了一截,高峰期的翻台率也有肉眼可见的提升。门店口碑在小红书上悄悄涨了一截。下一节是最后一个状态分支:客人不来了怎么办。

延伸阅读