案例拆解: 预约系统

Celoria 最核心的模块 — 用真实代码走完开发全流程的 8 个阶段

Feature #013 Appointment Booking · 82/82 tasks completed · 更新于 2026-03-24

模块概览 — 为什么从预约系统开始?

预约系统是 Celoria 的核心业务闭环起点。没有预约,就没有签到、没有服务交付、没有支付、没有留存。在 Business DAG 中,它是 Conversion(转化)层的核心节点。

它也是技术复杂度最高的模块之一:

Phase 1: 产品需求 — "我们要做什么?"

SPEC 文件位置

specs/appointments/013-appointment-booking/
├── spec.md ← 主需求文档(PRD)
├── data-model.md ← 数据模型设计
├── plan.md ← 实现计划(Design Doc)
├── tasks.md ← 任务拆分(82 个任务)
├── quickstart.md ← 快速上手指南
└── contracts/
    └── booking-api.yaml ← API 契约定义
└── checklists/
    └── requirements.md ← BDD 场景 & 验收清单

Spec 回答了哪些核心问题?

PRD 要素预约系统的答案
Problem 沙龙依赖电话/walk-in 预约,高峰期来电未接率高,客户流失严重
Target User 沙龙前台、经理(Web 后台)+ 客人(App/Web/Kiosk)
Goals 支持在线预约、多服务组合、技师自动分配、时间冲突检测
Non-Goals 不做:跨门店预约(v1)、AI 智能推荐时段(v1)、第三方日历同步
Success Metrics 在线预约占比 > 60%;冲突检测 0 遗漏;预约创建 < 3 秒
🟢 为什么要写 Non-Goals? Non-Goals 是在"砍需求" — 不写它们,scope 就会无限膨胀。 比如团队讨论中有人说"既然做了预约,干脆做跨门店预约吧",如果 Non-Goals 里写了"v1 不做跨门店",就可以直接引用文档来 scope down。

Phase 2: 用户旅程 — "用户怎么走?"

预约系统的 CUJ(关键路径)

CUJ-1: 前台创建预约(Web 后台)

登录 → Appointments Board → 点击 "New" → 选客人 → 选服务 → 选技师 → 选时间 → 提交 → Board 上出现新预约卡片

CUJ-2: 客人自助预约(Guest App / 公开页面)

打开预约页 → 选门店 → 选服务 → 选技师(可跳过=自动分配) → 选时间段 → 填信息 → 提交 → 收到确认

CUJ-3: Kiosk 签到 + 当天预约

Kiosk 欢迎页 → Check In(手机号查预约)→ 签到成功
               → Book Appointment(选服务→选技师→选时间→提交→自动签到)

CUJ 到 BDD 到 E2E — 三位一体

每条 CUJ 最终都要变成可执行的测试。这是从"抽象需求"到"代码验证"的转化链:

层级格式例子
CUJ
(需求层)
自然语言路径 "前台在 Board 上创建新预约"
BDD
(行为层)
Given / When / Then Given 拥有 appointments:create 权限
When 选择服务+技师+时间并提交
Then 预约创建成功,状态 confirmed
E2E Test
(代码层)
Playwright 脚本 tests/e2e/appointment-board.spec.ts
💡 这就是"需求可追溯性"(Requirements Traceability) 从 CUJ → BDD → E2E 的链条保证了:每个需求都被测试覆盖,每个测试都能追溯回需求。
面对 critic 时,你可以说:"这个功能的需求在 spec.md 第 X 行,BDD 场景在 requirements.md,E2E 在 appointment-board.spec.ts — 三层对齐。"
🔵 Celoria CUJ 文档位置 notes/product-design/web-appointments-cuj.html — Web 端预约 CUJ 完整地图

Phase 3: 技术设计 — "怎么做?"

系统分层架构

预约系统的代码组织严格遵循三层架构(Three-Tier Architecture):

📱 表现层 (Presentation Layer)
frontend/web_app/src/app/[locale]/[tenant]/appointments/
React 组件 → 用户看到的界面
↕ HTTP / WebSocket
🔌 API 层 (API / Route Layer)
backend/api/appointments.js + cart_appointment.js
Express 路由 → 参数验证 → 权限检查 → 调用 Service
↕ 函数调用
⚙️ 业务层 (Service / Business Logic Layer)
backend/services/booking/*.js
核心业务逻辑 → 冲突检测 → 可用性计算 → 通知调度
↕ SQL 查询
💾 数据层 (Data Access Layer)
backend/database/data-access/tenantDb.query()
PostgreSQL 多租户 Schema 隔离
🟢 为什么要分层? 关注点分离(Separation of Concerns)。
• API 层只负责"接收请求 + 检查权限 + 返回响应",不包含业务逻辑
• Service 层只负责"业务规则",不关心 HTTP 请求长什么样
• 数据层只负责"和数据库通信",不知道业务规则是什么

好处:如果以后要从 REST API 切到 GraphQL,只需要换 API 层,Service 层一行代码都不用改。

核心服务拆分

CODE 预约系统的 Service Layer 拆成了 10+ 个独立服务:

服务文件职责为什么独立出来
booking-service.js 核心预约操作(创建/修改/取消) 业务主逻辑,协调其他服务
conflict-detector.js 检测技师时间冲突 复杂的时间区间交叉判断,独立可测试
availability-calculator.js 计算可用时间段 依赖排班表 + 已有预约 + 营业时间
booking-lock.js 并发保护(Advisory Lock) 防止两人同时抢到同一时段
notification-dispatcher.js 发送确认/取消通知 整合 SMS (Twilio/Telnyx) + Email (SES/Resend)
auto-assign-service.js 自动分配技师 当客人不选技师时的智能匹配
group-booking-service.js 团体预约(一起来的) 多客人同时段预约的特殊逻辑
booking-config.js 业务规则配置 可配置的预约规则(提前天数、取消政策等)
booking-metrics.js 指标采集 追踪预约 KPI(成功率、取消率等)
💡 "单一职责原则"(Single Responsibility Principle)的真实体现 conflict-detector.js 只做一件事:判断两个时间段是否冲突。它不知道预约是什么,不关心 HTTP,不碰数据库。
这意味着你可以单独为它写 Unit Test,不需要 mock 数据库、不需要启动服务器。
这就是为什么好的架构能让测试更容易写 — 因为每个模块足够小、足够独立。

关键设计决策

决策 1: 多服务预约的数据模型

一个客人可能同时预约"美甲 + 足部护理"两个服务。怎么存?

方案做法优劣
方案 A
单行 + JSON
一行 appointment,services 用 JSON 数组存 ❌ 无法给每个服务独立指定技师和时间
方案 B ✅
主从结构
一个主预约 + N 个子预约(sub-appointments),每个子预约有独立的技师和时间 ✅ 灵活、可独立管理每个服务的状态和技师
方案 C
完全独立
每个服务就是一个独立预约,通过 group_id 关联 ❌ 无法区分"一个客人的多服务"和"不相关的预约"

最终选了 方案 B(主从结构)。数据库迁移 1802000000001_add-sub-appointment-independent-times.js 为子预约添加了独立时间字段。

🟢 这就是 "Alternatives Considered" 的价值 如果有人问"为什么不用 JSON 数组存多个服务?",你可以回答:
"我们评估了三种方案。方案 A(JSON 数组)无法给每个服务独立分配技师和时间;方案 C(完全独立)缺少父子关系的语义。方案 B(主从结构)在灵活性和数据完整性之间取得了最好的平衡。"

决策 2: 并发冲突怎么解决?

两个人同时预约同一技师的下午 2:00,怎么保证只有一个人成功?

方案做法优劣
方案 A
乐观锁
先写入,如果冲突就回滚 ❌ 用户体验差(提交后才说"时段已被占")
方案 B ✅
Advisory Lock
创建预约前先获取数据库级锁(按技师+时间),锁住后再检查+写入 ✅ 保证不会两人同时写入冲突预约
方案 C
Redis 分布式锁
用 Redis 的 SET NX 实现锁 ❌ 引入额外依赖(Redis),单 PostgreSQL 可以解决

选了 方案 B(PostgreSQL Advisory Lock),实现在 booking-lock.js。数据库层面还加了 UNIQUE constraint 做兜底:1778800000001_add-appointment-overlap-constraint.js

💡 "Belt and Suspenders"(皮带+吊带裤)策略 Advisory Lock 是第一道防线(应用层),Overlap Constraint 是第二道防线(数据库层)。
即使应用层的锁因为 bug 失效,数据库约束也能兜住。Stripe 在支付系统中大量使用这种双保险策略。

Phase 4: 架构图 — "可视化理解系统"

预约创建时序图 (Sequence Diagram)

这张图展示了"前台创建预约"时,各组件按什么顺序交互:

sequenceDiagram
    participant U as 浏览器
    participant API as Express API
    participant PM as Permission Middleware
    participant BS as booking-service
    participant CD as conflict-detector
    participant BL as booking-lock
    participant DB as PostgreSQL
    participant ND as notification-dispatcher
    participant SMS as Twilio/Telnyx

    U->>API: POST /api/appointments
    API->>PM: 检查 appointments:create 权限
    PM-->>API: ✅ 放行

    API->>BS: createAppointment(data)
    BS->>BL: acquireLock(technicianId, timeSlot)
    BL->>DB: pg_advisory_lock(hash)
    DB-->>BL: 🔒 锁定成功

    BS->>CD: checkConflict(technicianId, start, end)
    CD->>DB: SELECT FROM appointments WHERE overlap
    DB-->>CD: 0 rows (无冲突)
    CD-->>BS: ✅ 无冲突

    BS->>DB: INSERT INTO appointments
    DB-->>BS: 新预约 ID

    BS->>BL: releaseLock()
    BS->>ND: sendConfirmation(appointment)
    ND->>SMS: 发送确认短信
    SMS-->>ND: ✅ 发送成功

    BS-->>API: { appointment }
    API-->>U: 201 Created
    
🟢 时序图的价值 没有这张图,你只知道"调 POST /api/appointments 就能创建预约"。有了它,你知道了:
1. 请求经过了权限检查(PM)→ 加锁(BL)→ 冲突检测(CD)→ 写入(DB)→ 解锁 → 通知(ND)共 6 个步骤
2. 锁在写入前获取、写入后释放 — 这解释了为什么不会有并发冲突
3. 通知是在预约写入成功之后异步发的 — 即使短信发送失败,预约本身已经创建成功

预约状态机 (State Machine)

stateDiagram-v2
    [*] --> confirmed: 创建预约
    confirmed --> checked_in: 到店签到
    confirmed --> cancelled: 取消
    confirmed --> no_show: 超时未到

    checked_in --> in_progress: 开始服务
    checked_in --> cancelled: 签到后取消

    in_progress --> completed: 服务完成
    in_progress --> cancelled: 中途取消

    completed --> [*]
    cancelled --> [*]
    no_show --> [*]
    

详细版本见 notes/appointment-status-flow.html

数据模型 ER 关系

erDiagram
    appointments ||--o{ sub_appointments : "1:N 主从"
    appointments }o--|| guests : "属于客人"
    appointments }o--|| employees : "指定技师"
    appointments }o--|| services : "预约服务"
    appointments }o--|| centers : "所在门店"
    appointment_groups ||--o{ appointment_group_members : "团体成员"
    appointment_group_members }o--|| appointments : "关联预约"

    appointments {
        uuid id PK
        uuid guest_id FK
        uuid employee_id FK
        uuid service_id FK
        uuid center_id FK
        timestamp start_time
        timestamp end_time
        varchar status
        varchar source
        uuid parent_appointment_id
    }
    
🔵 已有的架构图文档notes/appointment-status-flow.html — 状态机交互图
notes/appointment-database-schema.html — 表结构详解
notes/concurrent-booking-race-condition.html — 并发竞态分析

Phase 5: 任务拆分 — "按什么顺序做?"

依赖顺序(预约系统的真实执行路径)

━━━ 第 1 层:数据基础(被所有人依赖)━━━
DB 创建/修改 appointments 表结构
DB 添加 overlap constraint(防冲突)
DB 创建 appointment_groups 表(团体预约)

━━━ 第 2 层:核心 Service(依赖数据库)━━━
CODE conflict-detector.js — 时间冲突检测
CODE availability-calculator.js — 可用时段计算
CODE booking-lock.js — Advisory Lock
CODE booking-service.js — 协调上述三者

━━━ 第 3 层:API 路由(依赖 Service)━━━
CODE api/appointments.js — CRUD 路由
CODE api/cart_appointment.js — 多服务批量创建
CODE Permission 配置 — appointments:create/view/manage

━━━ 第 4 层:前端页面(依赖 API)━━━
CODE Board 看板页(Kanban 时间线视图)
CODE Create 创建页(表单)
CODE Detail 详情页(查看/编辑)

━━━ 第 5 层:集成(依赖前端+后端)━━━
CODE notification-dispatcher.js — 短信/邮件通知
CODE booking-metrics.js — 指标采集

━━━ 第 6 层:测试(依赖一切)━━━
TEST Unit Tests — conflict-detector, availability
TEST API Tests — appointments.test.js
TEST Integration Tests — public-booking
TEST E2E Tests — appointment-board.spec.ts
💡 为什么数据库先做? 因为它被依赖最多。Service 层要查表,API 层要返回数据,前端要渲染字段 — 所有人都在等数据库 schema。
如果表结构没定好就开始写 Service,后面表结构一改,Service 代码全部要跟着改。
这就是"依赖图驱动"的任务排序 — 先做被依赖最多的,最后做依赖最多的(前端和测试)。

Phase 6: 开发实现 — "代码怎么写?"

API 路由的典型结构

CODE backend/api/appointments.js — 一个 API 路由的标准组成:

// 1. 权限检查(RBAC middleware)
router.post('/',
  requirePermission('appointments:create'), // ← 统一权限中间件

// 2. 参数验证(express-validator)
  body('guestId').isUUID(),
  body('serviceId').isUUID(),
  body('startTime').isISO8601(),

// 3. 处理函数(调用 Service,返回响应)
  async (req, res) => {
    const result = await bookingService.createAppointment(req, data);
    res.status(201).json(result);
  }
);
🟢 注意三层结构在代码中的体现 API 路由不包含业务逻辑。它做三件事:
1. 权限检查 → requirePermission()
2. 参数验证 → body().isUUID()
3. 调用 Service → bookingService.createAppointment()

如果你在 API 路由里看到 SQL 查询或复杂的 if/else 业务判断,说明代码结构有问题。

Service 的典型结构

CODE backend/services/booking/booking-service.js 的核心逻辑(简化版):

async function createAppointment(req, data) {
  // 1. 获取锁(防止并发冲突)
  await bookingLock.acquire(data.employeeId, data.startTime);

  try {
    // 2. 检查时间冲突
    const conflict = await conflictDetector.check(data.employeeId, data.startTime, data.endTime);
    if (conflict) throw new ConflictError('时间段已被占用');

    // 3. 写入数据库(在租户 schema 内)
    const appointment = await tenantDb.query(req, insertSQL, values);

    // 4. 发送通知(异步,不影响主流程)
    notificationDispatcher.sendConfirmation(appointment).catch(logger.error);

    return appointment;
  } finally {
    // 5. 无论成功失败,释放锁
    await bookingLock.release(data.employeeId, data.startTime);
  }
}
💡 注意第 4 步的 .catch(logger.error) 通知发送是异步且不阻塞的。即使短信发送失败,预约本身已经创建成功。
这是一个有意识的设计决策:预约创建是 P0(不能失败),通知发送是 P2(失败了可以重试)。
如果把通知放在事务里面(await 等结果),一旦 Twilio 宕机,所有预约创建都会被阻塞 — 这是不可接受的。

数据库层 — 多租户隔离

注意上面代码里用的是 tenantDb.query(req, ...) 而不是 pool.query(...)

这是因为 Celoria 是多租户架构 — 每个沙龙有自己的 PostgreSQL Schema(如 tenant_spa001)。tenantDb 会自动从 req 中读取当前租户信息,在正确的 schema 中执行查询。

⚠️ 绝对不能绕过 如果直接用 pool.query(),查询会在 public schema 中执行,可能读到别的租户的数据。
这是一个安全漏洞(多租户数据泄露),在 Celoria 中是"红线"级别的禁止事项。

Phase 7: 测试 — "怎么验证?"

预约系统的测试文件清单

层级文件测什么
UNIT tests/unit/services/appointment-payment-sync.test.js 支付同步逻辑(不碰数据库)
UNIT tests/unit/reports/operational-appointments.test.js 报表查询逻辑
API tests/api/appointments.test.js CRUD API(权限、参数验证、响应格式)
API tests/api/appointments-with-auth.test.js 认证场景(无 token、过期 token)
API tests/api/cart-appointment.test.js 多服务批量创建
API tests/api/appointments-merge-group.test.js 团体预约合并
API tests/api/appointments-unmerge-group.test.js 团体预约拆分
INT tests/integration/public-booking/my-appointments.test.js 公开预约 → 查看我的预约(完整流程)
E2E frontend/web_app/tests/e2e/appointment-board.spec.ts Board 页面操作(创建、拖动、状态变更)

RBAC 权限测试的标准模式

预约 API 的权限测试至少要覆盖两类:

// 测试 1: 无权限 → 403
it('should return 403 when user lacks appointments:create', async () => {
  const res = await request(app)
    .post('/api/appointments')
    .set('Authorization', noPermissionToken);
  expect(res.status).toBe(403);
});

// 测试 2: 有权限 → 非 403
it('should allow creation with appointments:create', async () => {
  const res = await request(app)
    .post('/api/appointments')
    .set('Authorization', hasPermissionToken);
  expect(res.status).not.toBe(403);
});
💡 为什么第二个测试是 "not 403" 而不是 "201"? 因为有权限不意味着一定成功 — 可能缺少必要参数(400)或技师不存在(404)。
权限测试只关心"权限检查是否正确",不关心后续业务逻辑。所以用 not.toBe(403) 而不是 toBe(201)
这就是"关注点分离"在测试中的体现 — 每个测试只验证一件事。

如何运行预约相关测试

# 运行所有预约相关的后端测试
cd backend && npx jest --testPathPattern=appointment

# 只运行 API 测试
cd backend && npx jest tests/api/appointments.test.js

# 运行前端 E2E 测试
cd frontend/web_app && npx playwright test appointment-board

# 运行所有测试(统一入口)
npm run test:all

Phase 8: 部署与监控 — "上线后怎么保障?"

预约系统的监控点

指标含义异常阈值
预约创建成功率成功创建 / 尝试创建< 95% 触发告警
冲突检测命中率被拒绝的冲突预约比例> 20% 可能意味着可用性计算有 bug
Advisory Lock 等待时间获取锁等了多久> 2 秒说明并发压力大
通知发送成功率短信/邮件成功发出的比例< 90% 触发告警(可能是 Twilio 问题)

熔断器保护

通知服务依赖外部提供商(Twilio/Telnyx 发短信,SES/Resend 发邮件)。如果 Twilio 宕机,notification-dispatcher.js 内的熔断器会:

  1. Open — 检测到连续失败后,自动切断 Twilio 调用
  2. Fallback — 自动切换到 Telnyx(备用提供商)
  3. Half-open — 过一段时间后尝试恢复 Twilio

熔断器状态可以在 /api/monitoring/circuits 查看。

全景总结 — 预约系统的文件地图

📋 需求与设计
├── specs/appointments/013-appointment-booking/spec.md ← PRD
├── specs/appointments/013-appointment-booking/plan.md ← Design Doc
├── specs/appointments/013-appointment-booking/data-model.md ← 数据模型
├── specs/appointments/013-appointment-booking/tasks.md ← 82 个任务
├── specs/appointments/013-appointment-booking/contracts/booking-api.yaml ← API 契约
└── notes/product-design/web-appointments-cuj.html ← CUJ 地图

💾 数据库
├── backend/database/migrations/..._013-extend-appointments-source.js
├── backend/database/migrations/..._add-appointment-overlap-constraint.js
└── backend/database/migrations/..._add-sub-appointment-independent-times.js

⚙️ 后端 Service
├── backend/services/booking/booking-service.js ← 核心协调
├── backend/services/booking/conflict-detector.js ← 冲突检测
├── backend/services/booking/availability-calculator.js ← 时段计算
├── backend/services/booking/booking-lock.js ← 并发锁
├── backend/services/booking/notification-dispatcher.js ← 通知调度
└── backend/services/booking/auto-assign-service.js ← 技师自动分配

🔌 API 路由
├── backend/api/appointments.js ← CRUD + 状态管理
└── backend/api/cart_appointment.js ← 多服务批量创建

📱 前端页面
├── frontend/web_app/src/app/[locale]/[tenant]/appointments/board/page.tsx
├── frontend/web_app/src/app/[locale]/[tenant]/appointments/create/page.tsx
└── frontend/web_app/src/app/[locale]/[tenant]/appointments/[id]/page.tsx

🧪 测试
├── backend/tests/api/appointments.test.js ← API CRUD
├── backend/tests/api/appointments-with-auth.test.js ← 认证
├── backend/tests/api/cart-appointment.test.js ← 批量创建
├── backend/tests/integration/public-booking/my-appointments.test.js ← 集成
└── frontend/web_app/tests/e2e/appointment-board.spec.ts ← E2E

📊 架构文档 (Notes)
├── notes/appointment-status-flow.html ← 状态机
├── notes/appointment-database-schema.html ← 表结构
└── notes/concurrent-booking-race-condition.html ← 并发分析
💡 关键收获
1. 一个模块的交付物不只是代码 — 从 spec.md 到 test.js,至少涉及 5 种不同类型的文件
2. 执行顺序由依赖关系决定 — 先数据库,后 Service,再 API,最后前端和测试
3. 每个设计决策都应该有 "为什么" — 不是"我们用了 Advisory Lock",而是"我们评估了三种方案后选了 Advisory Lock,因为…"
4. 测试是需求的可执行版本 — CUJ → BDD → E2E 三位一体,保证需求可追溯