预约服务:改期、批量提醒与迟到处理
预约提醒由预约的开始时间产生,不依赖参与者登录。登录时展示的是当前日程;后台提醒则覆盖用户不在线时的触达。
示例规则是“预约确认后,在开始前 24 小时和 30 分钟提醒,取消后停止发送”。如果预约创建时距开始只剩 20 分钟,可以只发送确认消息,不再补齐已经错过的两档提醒。
从一条提醒需求推导预约模型
以会议室预约为例:用户约好周五 10:00 的场次,希望提前收到提醒。只保存手机号与发送时间,可以完成一次发送;但会议改到 11:00 后,系统还需要知道这条提醒属于哪个场次的哪个版本。
| 候选方案 | 能解决什么 | 需要补充的约束 |
|---|---|---|
| 按手机号和时间保存发送任务 | 一次性人工通知 | 无法自然关联改期、取消和参与人变化 |
| 定期扫描近期预约 | 发现当前有效日程 | 提醒阶段去重、窗口补扫和参与人展开 |
| 确认或改期时保存提醒计划 | 追踪每轮日程变化 | 旧任务识别、成员变化和任务恢复 |
本篇用“场次名额、预约记录、参与人关系、提醒计划”分别保存事实。占位是否成功属于预约事务;提醒是否发送属于通知流程。把它们分开,才能在短信服务故障时保留有效预约。
改期与提醒的执行时序
改期先作为一个原子业务操作提交,提醒展开和渠道发送随后处理。这里只画“新场次可用”的成功改期,名额不足时整个改期回滚。
展开任务与渠道发送可以由不同 Worker 承担,图中合并为一个角色以突出顺序。成员独立的通知结果使一人失败不会触发整场重复发送。
预约需求与验收约束
提醒之前还要保证预约本身有效。示例采用固定场次与名额模型:同一请求不能重复占位,确认成功才安排提醒,改期必须成功占到新场次后才能释放旧名额。
这里一次会议预约占一个资源名额,可以关联多个通知参与人。若课程按实际人数占座,则应按席位数量扣减并保存对应占位关系,不能把下面的减 1 原样用于团体订课。
| 业务问题 | 示例决策 | 实现约束 |
|---|---|---|
| 一个场次是否允许多人预约 | 允许,最多为场次配置的容量 | 名额扣减与预约确认同事务 |
| 预约是否可修改 | 开始前可改,是否收费另行定义 | 接口携带预期版本 |
| 新参与人加入时是否补提醒 | 只补仍有价值的阶段 | 按参与人创建独立意图 |
| 通知多久算迟到 | 示例要求开始前完成提交 | 提醒带最晚发送时间 |
| 所有渠道失败怎么办 | 页面保留日程,记录失败供查询 | 预约有效性不依赖通知成功 |
如果业务是医生、房间等任意时间段互斥预约,需要额外的区间冲突约束;下面的名额扣减只适用于预先划分好的场次。
保存预约时间和版本
| 数据 | 用途 |
|---|---|
appointment_id、version | 区分同一预约的多次改期 |
starts_at、timezone | 保存确定的开始时刻与展示时区 |
status | confirmed、cancelled、completed 等状态 |
| 参与人关系与版本 | 决定谁有资格收到这一轮提醒 |
| 提醒阶段 | before_24h、before_30m,分别去重 |
跨时区预约不能只存“上午 9 点”。日历规则需要对应 IANA 时区,实际执行对应明确时刻。重复预约先生成具体场次,再为每场安排任务,避免改动一场后意外影响整个系列。
建议分别保存 appointment_slots、bookings、booking_participants、reminder_plans 与通知记录。预约请求键在用户范围内唯一;参与人关系使用稳定成员 ID,离开后重新加入时产生新成员轮次,避免沿用旧关系版本。
| 接口或处理器 | 输入 | 输出与边界 |
|---|---|---|
| 创建预约 | 场次、参与人、请求幂等键 | 预约 ID、版本和确认结果 |
| 改期 | 预约 ID、目标场次、预期版本 | 成功后的新版本;冲突需重新读取 |
| 取消 | 预约 ID、预期版本、请求键 | 幂等取消并释放原占位 |
| 查询日程 | 预约 ID | 最新时间、状态和提醒记录 |
| 提醒展开 Worker | 提醒计划 ID、预约版本、成员扫描检查点 | 分批创建参与人通知意图 |
确认、改期与取消怎样触发
确认预约时,在同一事务保存预约和提醒任务,或者保存 Outbox 事件。改期时增加版本并创建新计划;旧任务执行时发现版本不一致,记录 skipped。
提醒任务开始处理时,依次检查:
- 预约仍然确认,时间和版本与本次任务一致。
- 参与者仍在名单中,且没有取消这一路提醒。
- 当前时间仍处于可接受的提醒窗口。
- 这一参与者、这一场次、这一提醒阶段没有完成同渠道发送。
检查与外部发送之间仍有并发窗口。已经提交的短信不一定能撤回;消息中的链接应读取最新预约信息,不把旧任务参数当成当前日程。
事务与事件发布的衔接见 Outbox 模式,它解决本地提交与可靠投递之间的窗口,不保证外部通知恰好发送一次。
创建预约的事务
以下 PostgreSQL 18 片段在已有表结构下表达名额条件,:参数名 由驱动绑定:
UPDATE appointment_slots
SET remaining = remaining - 1
WHERE id = :slot_id
AND tenant_id = :tenant_id
AND starts_at > statement_timestamp()
AND remaining > 0
RETURNING id, starts_at;应用事务先取得请求幂等键的处理资格,再执行名额更新。更新成功后创建预约、成员和提醒计划,一起提交;任何一步失败都回滚扣减。重复请求等待原事务结果或返回已保存的预约,不再扣一次名额。
改期时按固定顺序锁定新旧场次与预约记录,校验版本和可修改时间。占用新名额、释放旧名额、增加预约版本和保存新提醒计划一起提交。取消也只有第一次从有效状态转换时释放名额。
remaining > 0 让最后一个名额的竞争发生在数据库写入时,而不是依赖两个请求各自读到的剩余数量。请求幂等键则处理另一种问题:同一个用户因响应丢失重发。库存条件不能代替请求去重,反过来也一样。
提醒计划如何展开
领取已到时间的 reminder_plan
读取预约,检查版本、状态与最晚发送时间
分批读取仍有效的参与人:
保存场次 + 预约版本 + 成员轮次 + 提醒阶段对应的通知意图
与该批展开检查点一起提交
展开完成后结束计划
渠道 Worker 再次检查预约与参与人,提交仍有效的通知展开期间新增的成员,不能只靠旧检查点覆盖:加入操作需要补建仍适用的通知,或使计划按新成员水位再次核对。移除成员则让已建立但尚未发送的意图在校验时退出。
1000 人同一时间预约怎样处理
需要先区分 1000 个独立预约,还是一场有 1000 名参与者的会议。
| 情况 | 拆分单位 | 结果记录 |
|---|---|---|
| 1000 个独立预约 | 每个预约与提醒阶段 | 各自检查时间、状态和版本 |
| 一场 1000 人会议 | 场次提醒计划再展开参与人 | 每个参与者、每个渠道独立保存 |
展开参与人时分批读取并保存通知任务,分页推进与任务保存原子衔接,或通过稳定业务键安全重扫。不要在一个任务里循环发完 1000 人再整体确认,进程中断后无法安全区分谁已收到。
提醒有明确的最晚发送时间。消费者按渠道限速,必要时在允许窗口内提前错峰;若额度不足以在会前完成,应提前发现容量缺口,而不是临时在整点无限提高并发。具体容量计算见会员批量处理。
执行晚了怎么办
| 到达时刻 | 示例处理 |
|---|---|
| 原定前 24 小时,实际前 23 小时 | 若仍有价值,可发送实际剩余时间 |
| 两档提醒同时积压到开始前 10 分钟 | 合并为一次仍适用的提醒 |
| 预约已经开始或结束 | 跳过过时的“即将开始”文案 |
| 已取消但取消事件尚未消费 | 发送前读取当前预约状态,阻止旧任务 |
“实际剩余时间”应在发送前计算,不能直接沿用创建任务时的“还有 24 小时”。
一次改期失败与成功的记录演变
假设预约 B17 已占场次 S10,预约版本为 3,用户请求改到 S11:
| 操作 | 应保留的记录 | 原因 |
|---|---|---|
| 第一次改期,S11 已满 | B17 仍关联 S10,版本仍为 3 | 改期失败不应丢掉原位置 |
| S11 释放名额后重新提交有效请求 | 新占位、旧释放、B17 版本 4 和新计划一起提交 | 避免只改时间却漏释放或漏提醒 |
| 版本 3 的提醒到达 | 检测到当前版本 4,跳过 | 旧任务身份仍在,但已失效 |
| 为版本 4 展开 1000 人,处理到 500 人退出 | 前 500 人的意图与检查点已经持久化 | 恢复只需继续或安全重扫 |
| 某成员在发送前退出 | 其未发送意图跳过,其他成员继续 | 一个成员变化不应取消整场提醒 |
如果取消与发送竞争,发送前的状态判断只能阻止尚未交给渠道的动作。测试时应分别观察“取消成功”与“外部消息是否已经提交”,而不是把两者当成一个原子结果。
应验证的结果
| 场景 | 应断言的结果 |
|---|---|
| 最后一个名额被两个请求同时争抢 | 只有一个预约确认,剩余名额不小于零 |
| 创建请求成功但响应丢失 | 相同请求键返回同一预约,不再次占位 |
| 改期目标已满 | 原预约与原名额保持有效 |
| 展开到第 500 人后进程退出 | 恢复后继续或安全重扫,不重复已建意图 |
| 发送前移除成员或取消预约 | 未发送意图跳过;已提交渠道的结果可追踪 |
| 积压跨过预约开始时间 | 过时提醒停止发送并记录原因 |
监控确认成功率、名额冲突、最老待展开计划、开始前提交成功比例与渠道失败。任务成功表示处理了对应提醒意图,用户是否真正看到需要独立回执。
腾讯会议集成方案中的重复通知入口
腾讯会议的教育场景 API 集成白皮书介绍了课程、答辩和宣讲等预约场景。宣讲方案特别指出:创建会议的 API 响应与会议创建 Webhook 都可作为通知入口;如果其他流程也依赖 Webhook,建议统一由回调触发通知,避免发两次。文档还区分取消单次周期会议与取消整个系列。
对应到本文,应先统一业务事件身份,再生成通知意图。API 成功响应、回调重投和补查结果可以汇入同一入口,用“预约身份 + 版本 + 通知阶段 + 接收人”识别相同动作。仅对每个 HTTP 请求分别去重,无法识别两条不同入口携带的同一业务事实。
周期课程还需要系列 ID 与单次预约 ID:取消一节课只失效该次提醒,调整系列规则则明确影响哪些未来实例。对于 1000 名参与者,保存一次预约事实后分批展开通知,不在创建会议请求中同步发送 1000 次。这些数据模型和批量策略是本文据集成场景提出的实现建议;白皮书没有披露腾讯会议内部的提醒调度算法。
腾讯会议回调协议:确认接收要放在正确的位置
腾讯会议的企业回调服务说明规定了响应时间、重试和成功响应格式。它要求的不只是 HTTP 200,还包括约定的响应正文;超时或响应不符合要求会重试。因此,一个通用 Webhook 框架即使自动返回 200,也可能无法完成该接口的成功确认。
应用可以在完成来源校验和接收记录持久化后,快速按协议确认,再异步展开参与人提醒。若在回调请求里同步通知 1000 名参与者,很容易超过供应商的等待时间,让重投与原请求同时展开同一批通知。重复事件应命中原接收结果,通知去重仍按业务身份进行。
如果接收记录没有保存成功,就不能先确认再依赖进程内队列补做;如果供应商的有限重试已经结束,则需要查询或人工核对来恢复。这一设计也适用于预约修改和取消事件,但每类事件的身份与状态转换应分别定义。
参考资料
腾讯会议官方接入说明:API 支持企业回调服务
腾讯会议官方方案:教育场景 API 集成白皮书
PostgreSQL 18:Date/Time Functions and Operators
AWS Prescriptive Guidance:Transactional outbox pattern
PostgreSQL 18:UPDATE:条件更新与 RETURNING