1.1 需求拆解:门店、档期、预约、核销
场景:一个预约单里装了多少东西
林姐的美甲店有三家门店,共 12 个服务位,菜单上 40 多个项目,从纯色甲油胶到光疗延长甲,时长从 60 分钟到 150 分钟不等。之前电话排班全靠前台脑子记,国庆那天翻车后,她提的需求听上去特别简单:客人自己能在小程序里约,到店能核销,她自己能在手机上看各店数据。
三家店的客流差异很大:社区店里退休阿姨多,工作日上午是主力;写字楼店全靠午休和下班后两小时,周末反而清淡;商场店节假日客流是平时的三倍。这意味着档期容量不能一刀切,必须按门店按星期按时段三维度配置,后来的容量表设计就是被这个差异逼出来的。
真坐下来拆,发现一个预约单里整整装了六个实体:门店、服务项目(时长不同)、员工(美甲师)、档期(哪天哪个门店几点开几个位)、预约(谁约了什么)、核销(到店销掉)。少了任何一个,后面写代码都会不停地打补丁。我们第一版漏掉了员工实体,结果美甲师请假没法处理时段容量,只能手工在数据库里减数字,差点酿成第二次国庆事故。
客人视角其实只有四个页面:选门店、选时间、填信息、到店出示码。但四个页面背后,六个实体互相牵制,任何一个字段没设计好都会在某个深夜爆雷。我把这六个实体和它们的表名列在下面,之后所有篇章都会反复引用这些表名,先混个脸熟。
| 实体 | 表名 | 关键字段 | 备注 |
|---|---|---|---|
| 门店 | t_shop | shop_id, name, status | 停业开关 |
| 服务项目 | t_service | svc_id, duration, price | 时长以分钟计 |
| 员工 | t_staff | staff_id, shop_id, role | 归属门店 |
| 档期 | t_slot | slot_id, shop_id, slot_date, start_min, capacity | 全书核心表 |
| 预约 | t_booking | book_id, slot_id, svc_id, status | 状态机 |
| 核销 | t_verify | verify_id, book_id, staff_id, verify_at | 到店写入 |
方案:先把状态机画出来
预约单的状态我们定了五档,所有状态变更只能走后端接口,小程序端不做任何状态计算。这条规矩定下来之后,后面退改、对账、漏斗统计全都顺了。状态机是预约系统的骨架,骨架歪了,上面挂的每块肉都会不停地抖。
举个反例:我们第一版图省事,客人取消时小程序直接把本地列表里的卡片标灰,结果没调后端接口,数据库里这条预约还是已预约,到店时前台说有预约,客人说已经取消了,两边大眼瞪小眼。从此状态只信后端,前端怎么显示都要以接口返回为准,并且每次进小程序先拉一次最新状态。
| 状态码 | 名称 | 含义 | 谁能改 |
|---|---|---|---|
| 0 | 待支付 | 下单未付,锁位 15 分钟 | 超时自动释放 |
| 1 | 已预约 | 支付完成,等到店 | 核销或取消 |
| 2 | 已核销 | 服务完成 | 不可回退 |
| 3 | 已取消 | 客人主动取消 | 释放容量 |
| 4 | 超时释放 | 锁位过期未付 | 回收容量 |
档期是另一个核心概念。一个时段不是约了就没了,而是一个容量:周六 14:00 的时段容量是 4,因为店里同时有 4 个美甲师能接客。预约就是占一个容量位,取消就还回去。容量思维是后面所有并发问题的根基,这一点在 档期设计 里会展开讲。定档期时还有一条时间粒度的取舍:粒度越细客人选择越多,但维护成本和错单概率都直线上升,我们最后选了 30 分钟作为最小粒度。
配置/代码:两张核心表
t_slot 是整个系统出镜率最高的表,结构如下:
CREATE TABLE t_slot (
slot_id BIGINT PRIMARY KEY AUTO_INCREMENT,
shop_id INT NOT NULL,
slot_date DATE NOT NULL,
start_min INT NOT NULL, -- 从 0 点起的分钟数
capacity INT NOT NULL DEFAULT 1, -- 该时段可接几位
booked INT NOT NULL DEFAULT 0, -- 已占位数
KEY idx_date (shop_id, slot_date)
);
用 start_min 而不是 TIME 类型,是因为分钟数做跨天比较和对齐都方便:14:30 就是 870,加个时长直接算,不用在 SQL 里转来转去。duration 不放在 t_slot 里,而放在 t_service 里,核销与排班时反查,避免同一段时间被两种时长描述。
t_booking 的核心字段同样精简,多放一个字段后面多一份维护成本:
CREATE TABLE t_booking (
book_id BIGINT PRIMARY KEY AUTO_INCREMENT,
slot_id BIGINT NOT NULL,
svc_id INT NOT NULL,
staff_id INT NULL,
openid VARCHAR(32) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
lock_expire DATETIME NULL, -- 待支付状态的锁位过期时间
create_at DATETIME NOT NULL
);
lock_expire 是占位锁的过期时间,专门解决下单后不付款赖着不走的场景,具体机制在 并发占位与锁 那篇详细展开。
建完两张表先别急着写接口,灌一批假数据进去测索引。我们当时往 t_slot 里灌了三个月约三万行,按 shop_id 加 slot_date 查单店单天,毫秒级返回;反之按 slot_date 单独查全部门店,直接走了全表扫描。索引建在 (shop_id, slot_date) 上而不是 date 上,是因为业务里 99% 的查询都带门店,先过滤门店再过滤日期才能让索引发挥最大价值。这个细节在数据量小的时候毫无存在感,等节假日流量上来就是另一副面孔了。
踩坑:四个被忽略的细节
第一个坑是项目时长。最初我们把档期按 30 分钟一个格子切,结果延长甲要 150 分钟,一个预约占 5 个格子,客人选 14:00 时系统得检查 14:00 到 16:30 全部格子有空位。后来改成按服务时长动态匹配可约起点,前端选项一下少了四分之三,客人反而更容易约到想要的时间。
第二个坑是门店停业。有家店装修三天,已有预约要批量迁走,发现没有门店级开关,只能逐个时段改状态。后来在 t_shop 加了 status 字段,停业时整店新预约入口关闭,存量预约走批量改期流程。
第三个坑是员工排班和档期脱节。美甲师请假,她名下的时段容量要减一,所以 t_staff 必须和排班关联,这部分在 员工端排班 里有详细方案。
第四个坑是手机号。我们最初把手机号存在预约单里当唯一标识,结果客人换了号码,历史预约全部对不上号。老老实实以 openid 为主键,手机号存 t_member 表里做通知用,登录态和通知态彻底分离。
这四个坑有一个共同点:全都是在需求拆解时聊到过、但没落到纸面上的问题。会议上一句这个回头再说,回头就是凌晨两点在生产环境里改数据。所以我们的需求清单只有两个验收标准:每个实体有表名,每个状态有迁移条件,多说一句都算超纲。
针对迁移我们还定了三条约定:存量纸质和电话预约不迁,只在新系统里手工录未来三天的;迁移期间双轨运行两周,前台同时记两本账;每日打烊后做一次对账,把两边的约会对齐。下表是迁移期间每天要对齐的字段,对不上就立刻查:
| 对账字段 | 电话记录 | 系统来源 | 对账口径 |
|---|---|---|---|
| 预约日期 | 本子上的日期 | t_slot.slot_date | 完全一致 |
| 时段 | 几点几位 | start_min 换算 | 误差 0 分钟 |
| 项目 | 客人口述 | t_service.svc_id | 时长一致即可 |
| 美甲师 | 指名 | t_staff.staff_id | 缺人时标空 |
| 定金 | 微信转账 | 支付流水 | 金额一致 |
小结
需求拆解阶段最重要的产出不是厚厚的文档,而是一张实体表加一个状态机。实体定错了,表要推倒重构;状态机定错了,每个业务分支都长出补丁。我们把六个实体、五个状态钉在墙上之后,后续两周的接口设计几乎没再返工,这大概是整个项目最值钱的一次会议。迁移双轨那两周虽然前台累一点,但对账机制把六处录入错误拦在了客人到店之前,值得一笔。下一节我们聊一个更费脑的问题:这套东西放在哪种服务器上跑最划算,是自建轻量服务器还是直接上云开发。
