排期文档里躺着一句无比简洁的话:"预约管理模块,支持在线预约、商户确认、到店履约。"项目进入第三天,需求评审刚过,正打算把这块数据基础打牢。我盯着这句话看了半天,心里很清楚今天要做到什么程度——把这一句话翻译成表结构、状态机、SQL事务,再把基础服务接口的雏形立起来。
预约管理几乎没有行业壁垒,但每个行业对"预约"二字的理解都不一样:诊所预约核心在号源,美容美发预约核心在技师时段,会议室预约核心在资源排期。把它落到基础数据开发层面,关键要回答三个问题:预约单的流转规则是什么?资源在时间轴上如何表达可约和不可约?并发场景下怎么保证数据一致?这篇文章顺着这三个问题展开,把预约管理的基础数据开发完整说清楚。
1. 预约管理到底在管什么:业务边界先理清楚
很多新人在接到"预约管理"需求时,第一反应是列功能列表——用户能创建预约、取消预约、查看预约,然后就开始建表写接口。这种思路不是不对,而是少了最关键的一步:搞清楚预约业务里"数据"到底在围绕什么转。
1.1 预约业务的两个核心域:资源与预约单
预约管理的数据世界可以拆成两个域:资源域和预约单域。
资源域描述的是"有哪些东西可以被预约"——门店、服务项目、技师、时间段。它们的实体关系是:一个门店下有若干服务项目,一个服务项目可能对应多个技师,每个技师的可约时间被切成一个个时段。资源域的核心任务是把"可约/不可约"表达清楚,所以它天然是面向时间轴的。
预约单域描述的是"谁在什么时候约了哪个资源的哪个时段"——一次预约行为产生一条单据。预约单本身不关心资源怎么排,它只关心"我当时约到了什么"。所以预约单是资源域的一次消费记录,也是后续履约、统计、对账的数据基础。
这两个域的关系有点像电影院的场次和电影票:场次(资源排期)摆在那里,卖一张票就少一个座位,但票本身是一条独立的数据,要记录购票人、座位号、入场状态。预约管理的基础数据开发,本质上就是在维护这两个域的数据,并处理好它们之间的联动。
1.2 三种经典预约形态:时段预约、号源预约、排队预约
不同业务对"预约"的理解差异,最终会反映到数据模型上。我按数据结构特点把预约形态分成三类,方便后续做表设计时对照。
| 预约形态 | 典型场景 | 资源表达方式 | 数据核心 |
|---|---|---|---|
| 时段预约 | 美容美发、宠物洗护、家庭保洁 | 技师/会议室的时间表,切成固定时长的时段 | 时段 + 资源 + 是否被占用 |
| 号源预约 | 诊所挂号、政务大厅、疫苗接种 | 某天某个科室/窗口放出N个号 | 日期 + 号源池 + 余号 |
| 排队预约 | 餐厅排队、车辆年检 | 一个队列,按顺序叫号 | 队列位置 + 预计等待时长 |
很多人做预约管理时容易犯一个错误:把时段预约和号源预约混在一起设计,试图用一张表兼容所有场景。结果就是字段语义模糊,"时段"和"号"表达的是完全不同的两种业务含义,查数、统计、展示全都别扭。
基础数据开发的第一步,其实是确定业务属于哪种形态,最多是哪种形态的组合。比如一个口腔诊所,既有"上午10:00这个时段约某个医生"的时段需求,又有"每天放30个初诊号"的号源需求,那就应该分开设计排期数据,而不是硬凑成一套。
1.3 从业务流反推功能清单
理清形态之后,功能清单就好推导了。预约管理的完整链路是:查询可约时段 → 提交预约 → 商户确认/自动确认 → 到店履约 → 完成/爽约,加上中途的取消和改期。
对应的数据操作是:
- 查询可约时段:读资源排期表,过滤已约满和停约状态
- 创建预约:校验时段可约 → 写入预约单 → 占用资源余量(三步在一个事务里)
- 取消预约:校验状态合法 → 改预约单状态 → 释放资源余量
- 改期:新时段占用 + 旧时段释放,两个动作要保证一致性
- 爽约标记:到约定时间未到店,由系统或管理员变更状态
- 查询预约单:按用户、按门店、按状态多维度查
这些功能的核心并不在"写一个接口",而在每一次数据操作都保证不破坏两条业务规则:资源不能被超卖,预约单状态不能乱跳。这两条规则贯穿整个基础数据开发,下面两节展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型设计:把预约拆成能落库的表
数据模型是预约管理的地基。地基歪了,后面所有功能都会跟着歪。我以最常见的时段预约为例,讲一套可以直接落地的表设计,同时说明每张表为什么这么设计。
2.1 预约单主表:核心字段与时区陷阱
预约单主表建议命名为 appointment,核心字段如下:
sql复制CREATE TABLE `appointment` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`appointment_no` VARCHAR(32) NOT NULL COMMENT '预约单号,业务唯一',
`store_id` BIGINT NOT NULL COMMENT '门店ID',
`resource_id` BIGINT NOT NULL COMMENT '资源ID,如技师/医生/会议室',
`resource_name` VARCHAR(64) NOT NULL COMMENT '资源名称快照,冗余',
`customer_id` BIGINT NOT NULL COMMENT '客户ID,C端用户或会员ID',
`customer_name` VARCHAR(64) NOT NULL COMMENT '客户名称快照,冗余',
`service_item_id` BIGINT DEFAULT NULL COMMENT '服务项目ID',
`service_item_name` VARCHAR(64) DEFAULT NULL COMMENT '服务项目名称快照',
`appointment_date` DATE NOT NULL COMMENT '预约日期,冗余,方便按天查询',
`appointment_start` DATETIME NOT NULL COMMENT '预约开始时间',
`appointment_end` DATETIME NOT NULL COMMENT '预约结束时间',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待确认 1已确认 2已完成 3已取消 4已爽约',
`source_channel` VARCHAR(20) NOT NULL DEFAULT 'APP' COMMENT '渠道来源',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`idempotent_key` VARCHAR(64) NOT NULL COMMENT '幂等键,防重复提交',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`created_by` VARCHAR(32) DEFAULT NULL COMMENT '创建人,C端为空',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_appointment_no` (`appointment_no`),
UNIQUE KEY `uk_idempotent_key` (`idempotent_key`),
KEY `idx_customer_id_status` (`customer_id`, `status`),
KEY `idx_store_date_resource` (`store_id`, `appointment_date`, `resource_id`),
KEY `idx_resource_time` (`resource_id`, `appointment_start`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约单主表';
几个字段设计的关键决策,单独说明一下。
appointment_no 建议用业务号而非主键。主键自增是内部标识,预约单号是给用户和客服看的,两者职责不同。生成规则可以用"业务前缀 + 年月日 + 序列",比如 AP202506120001,方便日志检索和口头报单。
resource_name、customer_name、service_item_name 这三个冗余字段,很多人觉得多余——反正能关联资源表、客户表、项目表查出来。但预约单是历史事实数据,它记录的是"当时那一刻的预约内容"。如果技师改名了、服务项目下架了,关联实时表查出来的是新的名称,历史和现实就混淆了。冗余快照的成本极低,换来的数据准确性价值很高。
时间字段里,appointment_date 是一个容易被忽略但很实用的冗余。按天查询预约列表、统计每日预约量的时候,直接查这一个字段走索引,比在 appointment_start 上做范围过滤高效得多。这也是基础数据开发里"以查询场景反推冗余字段"的典型做法。
2.2 资源与排期表:可约库存怎么表达
资源本身是静态的,动态的是它的可约状态,所以我拆成两张表。
资源表 resource 描述"谁可以被预约":
sql复制CREATE TABLE `resource` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`store_id` BIGINT NOT NULL COMMENT '门店ID',
`resource_type` TINYINT NOT NULL COMMENT '资源类型:1技师 2医生 3会议室 4设备',
`resource_code` VARCHAR(32) NOT NULL COMMENT '资源编码',
`resource_name` VARCHAR(64) NOT NULL COMMENT '资源名称',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_store_resource_code` (`store_id`, `resource_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='可预约资源表';
排期表 resource_schedule 描述"某个资源在某天某时段能不能约、还剩多少":
sql复制CREATE TABLE `resource_schedule` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource_id` BIGINT NOT NULL COMMENT '资源ID',
`store_id` BIGINT NOT NULL COMMENT '门店ID,冗余便于门店维度查询',
`schedule_date` DATE NOT NULL COMMENT '排期日期',
`time_slot_start` DATETIME NOT NULL COMMENT '时段开始',
`time_slot_end` DATETIME NOT NULL COMMENT '时段结束',
`total_count` INT NOT NULL DEFAULT 1 COMMENT '可约总量,时段预约通常为1',
`booked_count` INT NOT NULL DEFAULT 0 COMMENT '已约数量',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1可约 2约满 3维护',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_resource_slot` (`resource_id`, `schedule_date`, `time_slot_start`),
KEY `idx_store_date` (`store_id`, `schedule_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源排期表';
这张表的两个细节值得展开。
第一,为什么要冗余 booked_count 而不是实时 COUNT(*) 预约单?因为预约场景是典型的读多写少,用户反复刷新查询可约时段,压力全部落在读上。实时 COUNT 每次都要扫预约单索引,数据量大了容易拖垮数据库。冗余"已约数量"的代价是写的时候要处理加减一致性问题,但预约写入本来就是低频事务,用行锁保护完全可接受。
第二,UNIQUE KEY uk_resource_slot 是整个并发防超卖设计里最关键的一环。resource_id + schedule_date + time_slot_start 唯一,意味着同一个资源的同一个时段在数据库层面只能有一条排期记录。后续预约事务只需要对这一行做原子更新即可,天然杜绝了"两条预约单同时占用同一个时段"的问题。
2.3 容易被忽略的预约操作流水表
预约管理做时间长了会有一个痛感:状态错了、余量对不上、用户说"我明明取消了怎么还扣款",排查起来无从下手。订单表的 updated_at 只能看到最后一次变更时间,中间发生了什么完全不可见。
所以第三张表 appointment_log 不是锦上添花,而是必备:
sql复制CREATE TABLE `appointment_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`appointment_no` VARCHAR(32) NOT NULL COMMENT '预约单号',
`action` VARCHAR(32) NOT NULL COMMENT '动作:CREATE/CONFIRM/CANCEL/RESCHEDULE/COMPLETE/NO_SHOW',
`from_status` TINYINT DEFAULT NULL COMMENT '变更前状态',
`to_status` TINYINT DEFAULT NULL COMMENT '变更后状态',
`operator_type` TINYINT NOT NULL COMMENT '操作人类型:1用户 2商户 3系统',
`operator_id` VARCHAR(32) DEFAULT NULL COMMENT '操作人ID',
`remark` VARCHAR(255) DEFAULT NULL COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
PRIMARY KEY (`id`),
KEY `idx_appointment_no` (`appointment_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约操作流水表';
我见过太多预约系统的数据事故,最后都是靠操作流水表定位的。比如"某个时段的 booked_count 变成负数",翻开流水一看,某条取消操作的 SQL 在事务里释放了两次余量。没有这张表,这种问题基本只能靠猜。
3. 状态机与数据一致性:预约最容易被坑的地方
预约管理的所有复杂性,集中在状态流转和并发控制。这两块即使是不做开发的产品和运营也值得了解,因为很多业务规则的讨论最终都归结为"状态到底怎么走"。
3.1 预约单状态机:状态不能想改就改
预约单状态从创建到终态的完整流转是这样的:
待确认(0):用户提交预约,等待商户确认已确认(1):商户确认通过,或系统自动确认,预约生效已完成(2):用户到店核销,服务结束已取消(3):用户在确认前/确认后主动取消,或商户取消已爽约(4):预约时间已过且未到店、未取消
合法流转路径:
code复制待确认 -> 已确认
待确认 -> 已取消
已确认 -> 已完成
已确认 -> 已取消
已确认 -> 已爽约
非法路径是:已完成/已爽约的单不能取消;已取消的单不能回到已确认。这些规则看起来是常识,但在代码里如果没有统一封装状态变更入口,很容易出现"某个接口里直接 UPDATE status = 3"的情况,绕过了校验。
基础数据开发在处理状态机时有两条实用经验:
第一,状态变更入口收敛到一个方法里,任何操作都不能直接改预约单的 status 字段。比如 cancelAppointment() 方法内部先校验当前状态是否允许取消,再更新状态、写流水、释放资源。全项目统一走这个方法,就不会出现上面说的非法流转。
第二,数据库状态值用 TINYINT 数字,但代码里用枚举常量。不要在业务代码里到处写魔法数字 if (status == 3),维护性极差。定义一个 AppointmentStatusEnum,把状态值、描述、允许流转的目标状态都放在枚举里,状态机逻辑就变成查表。
3.2 并发预约与超卖:从数据库层面把死路堵死
并发超卖是预约管理最经典的问题,没有之一。场景是:一个技师某个时段只剩最后一个可约名额,两个用户同时点了预约,系统不能让两个人都约上。
常规思路是先查 booked_count < total_count,再更新排期表,但这两个操作之间存在时间窗口,并发时依然会超卖。正确做法是把"检查余量 + 占用余量"合并成一条 SQL,用数据库行锁串行化并发请求:
sql复制UPDATE resource_schedule
SET booked_count = booked_count + 1,
version = version + 1
WHERE id = ?
AND booked_count < total_count
AND status = 1;
这条 SQL 的关键在于 booked_count < total_count 条件。MySQL 的 UPDATE 会对命中的行加行锁,后到的请求必须等前面的请求提交才能继续执行,然后重新检查条件。当余量只剩 0 时,后到的 UPDATE 影响行数为 0,业务代码拿到影响行数判断为预约失败,返回"时段已约满"。
执行 UPDATE 之后,再插入预约单。如果插入失败,事务回滚,余量自动释放。这里必须保证"更新排期表 + 插入预约单"在同一个数据库事务里,不能先插预约单再更新排期,否则可能会出现预约单存在但余量没扣的脏数据。
完整的事务逻辑用伪代码表达:
text复制BEGIN TRANSACTION;
UPDATE resource_schedule
SET booked_count = booked_count + 1, version = version + 1
WHERE id = ? AND booked_count < total_count AND status = 1;
-- 影响行数 = 1 才继续,否则回滚
IF row_count = 0 THEN
ROLLBACK;
RETURN '该时段已被约满';
END IF;
INSERT INTO appointment (appointment_no, ..., status)
VALUES (..., '待确认');
INSERT INTO appointment_log (appointment_no, action, ...)
VALUES (..., 'CREATE', ...);
COMMIT;
这是一个非常经典的"先锁资源再落单据"模式,既保证了余量不超卖,又保证了预约单和排期余量的数据一致性。
3.3 取消与改期的数据联动
取消预约时,要做两件事:预约单状态改为已取消、排期表 booked_count 减一。这两步同样必须在同一事务里完成。
有一个细节容易踩坑:取消操作要判断当前状态。如果用户已经到店核销完成(状态为已完成),系统不能允许取消,否则就出现"服务做完了还能退款/取消"的逻辑漏洞。所以取消事务里的 UPDATE 要带上状态条件:
sql复制UPDATE appointment
SET status = 3, version = version + 1
WHERE appointment_no = ? AND status IN (0, 1);
UPDATE resource_schedule
SET booked_count = booked_count - 1, version = version + 1
WHERE resource_id = ? AND schedule_date = ? AND time_slot_start = ? AND booked_count > 0;
第一条语句影响行数为 0 时,说明预约单不处于可取消状态,整个事务直接回滚,也不需要执行释放余量的语句。这种"状态条件写在 UPDATE 里"的做法,比"先 SELECT 状态再判断再 UPDATE"更安全,因为它把检查和更新合并成了一个原子操作。
改期逻辑更复杂一点,本质是"新预约的创建 + 旧预约的取消"的组合。但顺序有讲究:先在目标时段执行占用余量,成功后把旧预约单取消并释放旧时段余量。如果先取消旧预约,再尝试占用新时段,而新时段此时已被别人约走,用户就会陷入"旧的没了、新的没约上"的尴尬境地。
4. 基础数据开发的落地实现:核心SQL与接口
基础数据开发不是只停留在表设计层面,最终要落成可运行的 SQL 和服务接口。这里给出最核心的几段 SQL 和一个基础接口清单,能直接复用到同类项目里。
4.1 查询可约时段:面向用户在实时查询场景
用户端打开预约页面,需要看到某个资源在接下来几天的时段列表。这个查询是高频读操作,必须走 resource_schedule 的索引:
sql复制SELECT
id AS schedule_id,
time_slot_start,
time_slot_end,
total_count,
booked_count,
(total_count - booked_count) AS available_count,
status
FROM resource_schedule
WHERE resource_id = ?
AND schedule_date BETWEEN ? AND ?
AND status != 3
ORDER BY time_slot_start ASC;
细化到查询结果的展示规则:available_count > 0 且状态为 1 的时段可约;available_count = 0 的时段如果业务上允许展示"约满"状态,可以返回给前端置灰,否则可以在 SQL 里加 HAVING available_count > 0 过滤掉。
这里要注意 schedule_date 和 time_slot_start 的过滤条件一致性:如果业务约定排期是按自然日生成,schedule_date 直接等于当天日期,time_slot_start 是具体的几点几分;查询时两个条件都加上,走 uk_resource_slot 唯一索引最稳妥。
4.2 预约事务完整 SQL:核心提交动作
基于上一节的状态机和并发方案,创建预约的完整事务 SQL 如下(采用 MySQL 语法):
sql复制-- 1. 原子占用余量
UPDATE resource_schedule
SET booked_count = booked_count + 1,
version = version + 1
WHERE id = ?
AND booked_count < total_count
AND status = 1;
-- 2. 插入预约单(需要在事务里通过程序判断上一步影响行数)
INSERT INTO appointment (
appointment_no, store_id, resource_id, resource_name,
customer_id, customer_name, service_item_id, service_item_name,
appointment_date, appointment_start, appointment_end,
status, source_channel, idempotent_key, created_by
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?);
-- 3. 插入操作流水
INSERT INTO appointment_log (
appointment_no, action, from_status, to_status,
operator_type, operator_id, remark
) VALUES (?, 'CREATE', NULL, 0, ?, ?, ?);
在代码层面,第 1 步执行后立即判断影响行数,不为 1 就抛出业务异常触发回滚,这样第 2、3 步不会执行。idempotent_key 的唯一索引是最后一道防线:即使同一个幂等键因为重试被提交两次,第二个 INSERT 会因唯一键冲突失败,整体回滚,不会生成两条预约单。
4.3 超时未确认的自动释放:定时补偿任务
很多预约场景有"用户预约后 X 分钟内不支付/商户不确认,系统自动取消并释放资源"的规则。这类逻辑不能依赖用户下次登录时触发,需要一个定时任务兜底扫描。
sql复制-- 找出超过30分钟仍未确认的待确认预约单
SELECT appointment_no, resource_id, appointment_date, appointment_start
FROM appointment
WHERE status = 0
AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 200;
扫描到之后逐条执行取消事务,释放余量,写流水,标记状态为已取消,备注写"系统超时自动取消"。定时任务要设计成"批量拉取 + 逐条处理"的模式,避免一次性拉全表造成锁竞争。同时每次执行要记录任务日志,方便监控超时取消是否正常运转。
4.4 基础服务接口设计
基础数据开发阶段,服务层接口先按功能域拆好,不用一次做全,但核心的创建、取消、查询必须有:
| 接口 | 方法 | 入参要点 | 出参 |
|---|---|---|---|
| 查询可约时段 | GET /schedule | storeId, resourceId, startDate, endDate | 时段列表 + 余量 |
| 创建预约 | POST /appointment | scheduleId, customerId, serviceItemId, idempotentKey | 预约单号 + 状态 |
| 取消预约 | POST /appointment/cancel | appointmentNo, operatorType, operatorId, 取消原因 | 影响结果 |
| 查询预约单 | GET /appointment | appointmentNo 或 customerId + status | 预约单详情 + 流水 |
接口参数设计上,idempotentKey 必须由客户端生成并随创建预约请求一起提交,服务端校验唯一。原因很简单:用户手滑点了两下提交,或者请求超时后客户端自动重试,服务端无法区分这是否是同一次预约意图。有了幂等键配合唯一索引,重复提交才能被安全拦截。
5. 上线前后容易踩的坑:时间、状态与数据对账
预约管理做完并不等于完事,真正考验数据开发水平的是上线后各种边界场景。下面这几个坑,是我在同类项目里反复遇到的,提前写出来帮大家绕开。
5.1 日期边界与跨天时段
预约单按天查询最怕跨天时段。比如一个凌晨 00:30 结束的预约,它 appointment_date 属于前一天,但 appointment_end 已经到第二天。如果统计报表按 appointment_end 分桶,就会把这条数据算到第二天;按 appointment_date 分桶,才算在第一天。关键是一切统计口径要统一,提前约定好"预约归属日期一律以 appointment_date 为准",并且把这条规则写进数据字典。
另外,排期生成时注意时段是否允许跨天,如果允许,time_slot_start 和 time_slot_end 就不能只看日期,必须都带时间。查询余量的时候,schedule_date = '2025-06-12' AND time_slot_start >= '2025-06-12 00:00:00' 这样的条件才能正确把跨天时段筛出来。
5.2 状态下沉到底查哪个字段
预约单状态和排期状态是两套体系,容易搞混。运营后台看"某个时段是否约满",不能只看 resource_schedule.status = 2,因为状态可能因为手工维护被改成 3(维护中),而真正约满与否要看 booked_count >= total_count。我的习惯是:status 字段只表达"运营手工干预状态"(维护/停用),约满与否用 booked_count 和 total_count 计算得出,避免双重状态更新导致数据打架。
5.3 余量数据对账与监控
预约系统上线后,每天要跑一次数据对账:遍历当日所有 resource_schedule,用预约单表统计实际占用数,和 booked_count 对比。发现不一致说明有事务逻辑漏洞,要立即查流水表定位。
对账 SQL 大致长这样:
sql复制SELECT
s.id AS schedule_id,
s.booked_count,
COUNT(a.id) AS actual_booked_count
FROM resource_schedule s
LEFT JOIN appointment a
ON a.resource_id = s.resource_id
AND a.appointment_date = s.schedule_date
AND a.appointment_start = s.time_slot_start
AND a.status IN (0, 1)
WHERE s.schedule_date = CURRENT_DATE()
GROUP BY s.id, s.booked_count
HAVING s.booked_count != actual_booked_count;
这组 SQL 跑不出来还好,跑出来基本就能看到是哪些时段对不上,再结合流水表逐一排查是创建事务没扣余量、取消事务多放余量,还是定时任务重复释放。
5.4 写代码前先画状态机图,省一半返工
最后给接这类项目的新人一个实质性建议:别急着建表写接口,先和产品一起把状态机图画清楚。状态机图不需要多专业,只要把每个状态允许跳转到哪些状态标清楚,接口设计的边界条件就全出来了。我见过太多预约项目做到一半,产品突然说"已完成的单子要支持取消"或者"取消的单子要能重新激活",这些需求落到数据层就是状态流转图的修改,代价极大。前期多花一小时画图,后期能省一整天的返工。
实际操作中我还有个习惯:状态机图确定后,把每条合法流转路径写成一个单元测试用例。这样任何一次代码重构,只要跑一遍测试,就知道有没有人偷偷放宽了状态限制。基础数据开发看起来简单,但正是这些"看起来简单"的约束,决定了系统在复杂业务场景下能不能站得住。
