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 旁边,核心结构一行没动。下一篇聊怎么让约好的客人一定来——通知触达。

延伸阅读