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 缺人时标空
定金 微信转账 支付流水 金额一致

小结

需求拆解阶段最重要的产出不是厚厚的文档,而是一张实体表加一个状态机。实体定错了,表要推倒重构;状态机定错了,每个业务分支都长出补丁。我们把六个实体、五个状态钉在墙上之后,后续两周的接口设计几乎没再返工,这大概是整个项目最值钱的一次会议。迁移双轨那两周虽然前台累一点,但对账机制把六处录入错误拦在了客人到店之前,值得一笔。下一节我们聊一个更费脑的问题:这套东西放在哪种服务器上跑最划算,是自建轻量服务器还是直接上云开发。

延伸阅读