本文约 4,126 字,预计阅读 11 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:两个客户约了同一个下午三点
做美容店、诊所、摄影棚、家政服务的预约站,最严重的事故不是没人约,而是撞单——两个客户都约到周一下午三点,技师只有一个,现场只能道歉赔偿。这种事故往往不是插件的问题,而是配置的问题:服务时长设了 60 分钟,但时段粒度也是 60 分钟,缓冲(清洁、准备)时间为 0,日历看起来排满了,实际根本转不过来。
另一个常见问题是时区。客户在纽约,服务方在洛杉矶,前端展示的”下午三点”到底是谁的三点?WordPress 的站点时区、数据库存储的时间戳、前端展示的本地时间,三者如果不统一,撞单会以更隐蔽的形式出现:系统里看是错开的,客户理解成同一时间。
原理分析:预约系统的核心是时段模型
| 概念 | 含义 | 典型值 | 配错会怎样 |
|---|---|---|---|
| 服务时长 Duration | 单个预约占用的时间 | 30 / 60 分钟 | 过短导致排不下 |
| 时段粒度 Slot | 可预约的起点间隔 | 30 分钟 | 与时长不匹配产生碎片 |
| 缓冲 Buffer | 两次预约之间的空档 | 5–15 分钟 | 为 0 会导致实际串行失败 |
| 容量 Capacity | 同一时段可接几单 | 1(单人服务)/ N(课程) | 为 1 时并发必撞 |
| 提前/截止 Lead/Cutoff | 最早最晚可约范围 | 提前 2 小时 / 30 天 | 太短会接到临时单 |
冲突检测的数学表达很简单:新预约区间 [start, start+duration+buffer) 与已有预约区间存在交集,即冲突。关键在于必须以 UTC 时间戳做比较,展示时才转成本地时间。把时间存成本地字符串再比较,跨时区一定会出错。
保姆级实操
步骤 1:选插件,按业务复杂度决定
单人服务(美容、摄影、咨询)用 Amelia 或 Simply Schedule Appointments 足够,界面友好、自带提醒;多服务项目 + 员工排班 + 收费,用 Bookly 或 MotoPress Appointment Booking;如果本身就在用 WooCommerce 并且要按资源计费,用 WooCommerce Bookings。不要为了”功能全”选最重的,配置成本会翻倍。
步骤 2:把服务时长、缓冲、容量一次设对
在插件的服务设置里,务必填三个值:Duration 60 分钟、Buffer 10 分钟、Capacity 1(或场地座位数)。然后确认时段粒度与时长一致或为其约数——时长 60、粒度 30 会生成大量无法利用的碎片时段。
步骤 3:设置可预约窗口
Lead time(最少提前多久)建议 2 小时,避免接到 10 分钟后的单;Cutoff(最多提前多久)建议 30–60 天,防止远期占位的无效预约导致近期排满。
步骤 4:自建时段生成与冲突检测(进阶)
如果你用自定义 CPT 自己实现,下面这段是可直接复用的时段生成 + 冲突校验逻辑。已预约记录存在 appointment 文章类型的 _slot_start(UTC 时间戳)和 _slot_end 两个 meta 里:
function mf_generate_slots( $date_ymd, $duration = 3600, $buffer = 600, $open = '09:00', $close = '18:00' ) {
$tz = wp_timezone();
$base = new DateTimeImmutable( $date_ymd . ' ' . $open, $tz );
$end = new DateTimeImmutable( $date_ymd . ' ' . $close, $tz );
$slots = array();
for ( $t = $base; $t->getTimestamp() + $duration <= $end->getTimestamp(); ) {
$slots[] = array(
'start' => $t->getTimestamp(),
'end' => $t->getTimestamp() + $duration + $buffer,
);
$t = $t->modify( '+30 minutes' );
}
return $slots;
}
function mf_slot_taken( $start, $end ) {
$q = new WP_Query( array(
'post_type' => 'appointment',
'posts_per_page' => 1,
'post_status' => 'publish',
'meta_query' => array(
'relation' => 'AND',
array( 'key' => '_slot_start', 'value' => $end, 'compare' => '<', 'type' => 'NUMERIC' ),
array( 'key' => '_slot_end', 'value' => $start, 'compare' => '>', 'type' => 'NUMERIC' ),
),
) );
return $q->have_posts();
}
上面的 meta_query 就是标准的区间重叠判断:existing.start < new.end AND existing.end > new.start。
步骤 5:写入预约前再做一次原子校验
前端禁用按钮不算数,必须在服务端写入前再查一次,因为两个用户可能同时提交:
function mf_book_slot( $start, $end, $customer ) {
if ( mf_slot_taken( $start, $end ) ) {
return new WP_Error( 'slot_taken', '该时段已被预约,请选择其他时间。' );
}
$id = wp_insert_post( array(
'post_type' => 'appointment',
'post_title' => '预约 ' . wp_date( 'Y-m-d H:i', $start ),
'post_status' => 'publish',
) );
update_post_meta( $id, '_slot_start', $start );
update_post_meta( $id, '_slot_end', $end );
update_post_meta( $id, '_customer', sanitize_text_field( $customer ) );
return $id;
}
高并发场景下还要给写入加锁,WordPress 可以用 wp_cache_add() 做一个短时锁,或者直接在数据库给 _slot_start 建唯一索引。
步骤 6:提醒通知按两个时间点发
提前 24 小时一封(含地址、导航链接、改约链接),提前 2 小时一封(短提醒)。用插件内置的提醒功能或 Action Scheduler(WooCommerce 自带的任务队列库)来定时发送,不要用 WP-Cron——WP-Cron 依赖访问触发,低流量站点会延迟。
步骤 7:改约与取消要留口子
给确认邮件里加一个带 token 的取消链接,客户能自助取消,空出的时段自动回到可选池。自助取消能把”放鸽子率”压低一大截。
常见踩坑
- 缓冲时间设为 0:理论上排满,实际上技师连轴转,客户体验崩坏,差评增多。
- 用本地时间字符串比较:跨时区、跨夏令时必然出错,必须存 UTC 时间戳。
- WP-Cron 发提醒:低流量站点半夜没人访问,提醒延迟到第二天上午,等于没发。用 Action Scheduler 或服务器真实 cron。
- 前端禁用时段却不在服务端校验:用户改一下 HTML 就能提交撞单。
- 提醒邮件进垃圾箱:预约类邮件送达率要求极高,SPF/DKIM/DMARC 必须配齐。
- 案例:某美容工作室的”双 booked”:服务时长设 60 分钟,时段粒度 30 分钟,缓冲 0,结果系统允许 14:00 和 14:30 两个预约,而实际服务要 70 分钟,第二个客户到店后等了 40 分钟,当场投诉并退款。调整成粒度 60、缓冲 10 后,日历排布与实际产能吻合,投诉归零。
- 没有自助取消入口:客户只能打电话取消,高峰期占线,放鸽子率超过 20%。
总结
预约站是否可靠,用三个指标检验:撞单率是否为 0、提醒到达率是否高于 98%、自助取消率是否高于 50%(说明客户愿意提前告知)。行动清单:先把时长、粒度、缓冲、容量四个参数一次设对并确认它们互相匹配,再把所有时间统一存成 UTC 时间戳并在展示层转换,然后在服务端做区间重叠校验而非依赖前端禁用,最后用 Action Scheduler 在两个时间点发提醒邮件并在邮件里给出自助取消链接。这套做完,预约站才敢接真实客流。邮件链路的配置可参考 外贸站邮件营销 EDM 接入,如果预约入口要做成独立投放页,见 落地页高性能制作。
