2.4 退改规则:超时与取消
场景:约了不来,位子留给谁
预约系统最现实的问题不是怎么约,而是约了不来怎么办。美甲店的时段是库存,客人不取消也不出现,这个时段就白白空着——周末晚上六点的黄金时段被放鸽子,对林姐来说就是真金白银的损失。设计退改规则之前,林姐给了我们一本账:以前电话预约的爽约率在 18% 上下,最高的一天五个客人三个没来,前台只能捏着鼻子把位子重新挂出去,往往也来不及。
我们需要一套规则,既能保护门店的时段价值,又不能把客人得罪死。规则太松,客人随便约随便鸽;规则太严,客人不敢约,更多人选择随到店排。平衡点怎么找,就是本节的主题。
理想中的退改规则要做到三件事:客人改期比重新约更方便、取消越早代价越小、恶意爽约有代价但可申诉。林姐翻了翻自己的记账本,说前两条她要,第三条她要得更狠——去年国庆有个客人连续爽约四次,前台还得陪着赔笑脸,这次说啥也得让系统挡在前面。
方案:三段式退改规则
我们按距离服务时间的远近,把退改分成三档,配合 t_booking 的状态机一起执行:
| 档位 | 时间条件 | 规则 | 系统动作 |
|---|---|---|---|
| 宽松 | 24 小时前 | 免费取消,秒退 | 直接释放容量 |
| 标准 | 2 到 24 小时 | 扣 30% 违约金 | 释放剩余容量 |
| 严格 | 2 小时内 | 不退款,记爽约 | 爽约累计三次限约 |
三档之间的边界不是拍脑袋定的,而是拿林姐半年的爽约数据算出来的:距离服务 24 小时以上取消的客人,九成是行程变动;2 小时以内才取消的,多半是临时起意。违约金定 30% 也是试出来的——最初定 50% 吓退了一批想改期的客人,大家干脆不取消直接放鸽子;定 10% 又起不到约束作用。30% 这个数字让客人觉得心疼但还能接受,大多数人的选择是改成下周而不是硬取消。规则上线后头一个月,爽约率从 18% 降到 7%,大部分被挡在宽松档里自己改期了。
规则之外还有一条人文通道:店长有权限手工全退,比如客人发高烧临时去医院,爽约记录可以申请撤销。系统把规则执行到 95%,剩下 5% 留给人情。
退改规则落到状态机上,就是四条迁移路径,每条路径对应一个触发源,谁能在什么状态下改动单子,事先必须约定死:
| 迁移 | 起点 → 终点 | 触发方 | 前置校验 |
|---|---|---|---|
| 主动取消 | 已预约 → 已取消 | 客人小程序 | 状态为已预约,未过服务时间 |
| 超时释放 | 待支付 → 超时释放 | 定时任务 | 超过 15 分钟未支付 |
| 爽约标记 | 已预约 → 已核销(爽约) | 定时任务 | 服务后 30 分钟仍未核销 |
| 店长撤销 | 已取消 → 已关闭 | 店长后台 | 记录操作人与原因 |
最容易被忽略的是第四条:店长撤销不是把单子改回已预约,而是改成已关闭归档。曾经的版本里我们偷懒做了状态回退,结果一单在同一时段被释放又占用,账面对不上,排查了一个下午才定位到是回退路径没有锁。
退款这一侧我们单独建了一张流水表,和订单表解耦,订单只管业务状态,钱的事儿全交给流水:
CREATE TABLE t_refund (
refund_id BIGINT PRIMARY KEY AUTO_INCREMENT,
book_id BIGINT NOT NULL,
origin_amt INT NOT NULL COMMENT '原价,分',
penalty INT NOT NULL COMMENT '违约金,分',
actual_amt INT NOT NULL COMMENT '实退金额,分',
state TINYINT NOT NULL COMMENT '0 处理中 1 已退款 2 已关闭',
UNIQUE KEY uk_book_refund (book_id, state)
);
唯一键 uk_book_refund 的设计是故意的:同一笔订单同时只能存在一条处理中的退款,数据库层面把重复退款请求挡住,比在应用层加锁更稳。
配置/代码:取消接口与释放任务
取消接口的核心是状态校验加退款计算,接口入口先做三道校验:订单存在、状态是已预约、当前时间早于服务开始时间,任何一道不过都返回明确错误码而不是静默成功:
func cancelBooking(c *gin.Context) {
b := loadBooking(c.Query("book_id"))
hours := hoursUntil(b.slotStart())
switch {
case hours >= 24:
refund(b, b.price) // 全额退款
case hours >= 2:
refund(b, b.price*7/10) // 退七成
default:
markNoShow(b.openid) // 记爽约
}
releaseSlot(b.slotID) // 统一入口归还容量
}
无论走哪个分支,归还容量的动作永远只调 releaseSlot 这一个函数,它内部用原子更新保证不重复不减错:
UPDATE t_slot SET booked = booked - 1
WHERE slot_id = ? AND booked > 0;
客人不来的场景靠另一条定时任务兜底:服务时间过后 30 分钟,状态仍是已预约的单子自动标记为爽约,容量自然恢复。这条任务每十分钟跑一次,代码逻辑与取消接口共享同一个释放函数:
for _, b := range listExpiredBookings(now) {
if err := releaseSlot(b.slotID); err == nil {
markNoShow(b.openid)
notifyStaff(b.shopID, "爽约提醒:" + b.bookingNo)
}
}
爽约标记不只影响这一单,还会累计到客人身上。限约规则我们也分了档:累计一次只是提示,两次高峰时段限约,三次全部时段限约七天。规则看起来繁琐,但林姐坚持要给客人留改正机会,所以每次爽约都会触发一条短信告知客人当前累计次数,避免客人不知不觉被限约。
踩坑:四个退改现场的坑
第一个坑是退款重复发起。客人在微信支付的退款页操作一次,又到我们小程序里点一次取消,两笔退款同时走到接口。处理时先查退款流水表,已有进行中的退款直接返回成功,避免把单退两次。有一天晚上对账发现某个订单实退金额比订单总额还多,追查下来正是并发退款,从此接口入口加了唯一键兜底。
第二个坑是释放容量时的临界值。极端情况下 booked 已经是 0,再来一条减一请求会变成负数,给后面约的客人显示虚假余位。SQL 里加 booked 大于 0 的条件后,这种异常被静默挡住。
第三个坑是违约金开票。微信退款退的是客人实际支付金额,但订单上的金额字段是原价,财务对账时两边对不上。后来退款流水里同时记录原价、违约金、实退三列,账目从此清清楚楚。
第四个坑是爽约限约的误伤。有些客人是真的临时生病,被系统限约后在群里抱怨。我们加了申诉入口,店长一键解除,并且限约只在高峰时段生效,平常时段不影响预约。
第五个坑是改期与取消的边界。客人把周五的单子改到周六,系统如果按取消再重下的流程走,就要先扣违约金再退款,客人的钱包来回波动,账面也多了两条流水。后来我们单独做了改期接口:只要目标时段有余量且距离原时段超过 24 小时,直接原子地完成一减一增,不产生任何资金流水。这个功能上线后,改期占了退改总量的六成,真正的取消单下降了一半。
| 操作 | 是否产生退款 | 容量变化 | 生效条件 |
|---|---|---|---|
| 改期 | 否 | 原时段减一,新时段加一 | 新时段有余量 |
| 取消(24 小时前) | 全额 | 原时段减一 | 状态为已预约 |
| 取消(2 到 24 小时) | 退七成 | 原时段减一 | 状态为已预约 |
| 爽约 | 否 | 定时任务自动释放 | 服务后 30 分钟 |
小结
退改规则的本质是在库存周转和客人体验之间走钢丝。三档规则加一个统一释放入口,再配上限约与申诉的人性化出口,让林姐的门店爽约率降到了 5% 以下,而投诉几乎归零。规则写在代码里是硬的,但执行规则的人是软的,给店长留一个 override 的按钮,是这套设计里最有温度的一部分。
回头看整套退改设计,真正值钱的不是那三档规则,而是三张表之间的关系:t_booking 管状态、t_slot 管容量、t_refund 管钱。三者通过 book_id 串起来,任何一次取消都能反查出容量有没有归还、钱有没有退对。后来林姐想加积分抵扣,也只是在这条链上挂在 t_refund 旁边,核心结构一行没动。下一篇聊怎么让约好的客人一定来——通知触达。
延伸阅读
- 状态机与容量机制,见 预约下单:并发占位与锁
- 到店环节的闭环,翻 到店核销:扫码与状态回写
- 通知触达全链路,见 触达策略:多通道兜底
- 退款流水与对账实践,翻 小程序商城实战
