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 防伪加状态机约束,三件套保证了三秒完成、不可伪造、留痕可查。林姐后来最常夸的就是这一环:前台再也没排队,客人觉得这家店科技感十足,本来前台核销最怕客人在旁边等,现在三秒搞定,整个店铺的运转节奏都快了一截,高峰期的翻台率也有肉眼可见的提升。门店口碑在小红书上悄悄涨了一截。下一节是最后一个状态分支:客人不来了怎么办。
延伸阅读
- 状态机的完整定义,见 需求拆解:门店、档期、预约、核销
- 占位与锁的实现,翻 预约下单:并发占位与锁
- 对象存储的用法,见 技术栈对齐:后端框架与短信地图
- 核销算提成怎么落到员工端,见 员工端:排班与接单
- 核销数据如何进入漏斗,见 数据看板:预约转化漏斗
