Web 公开预约重构 — Group Appointment 架构设计

更新日期: 2026-02-05

相关功能: 014-web-public-booking(重构)

参考实现: Guest Mobile App (Flutter) + 管理后台 BookingWizard

1. 用户操作流程

1.1 核心理念

对齐 Guest Mobile App:逐个服务配置,不是批量多选。每个服务独立选技师和时间,选完自动入购物车。支持多 Guest(Group Appointment)。

1.2 完整流程图

flowchart TD A[服务列表页
主页面] -->|点击某个服务| B[技师选择页] B -->|选择技师| C[时间选择页] C -->|选完时间| A C -.->|返回| B B -.->|返回| A A -->|底部购物车指示栏
点击打开| D[购物车弹框] D -->|编辑某项| B D -->|移除某项| D D -->|添加 Guest| E[填写 Guest 姓名/手机] E -->|确认| A D -->|结账| F[验证/确认页] F -->|手机验证 + 确认| G[预约成功页] style A fill:#e8f5e9 style D fill:#fff3e0 style F fill:#e3f2fd style G fill:#c8e6c9

1.3 逐步详解

步骤 页面 用户操作 说明
1 服务列表 浏览服务,点击一个服务 单选(非多选),点击后进入技师选择。服务可重复添加。底部有购物车指示栏(已选数量、总价)
2 技师选择 选择"任意可用"或某位技师 返回按钮 → 回到服务列表(放弃当前配置)
3 时间选择 选日期 + 选时段 返回按钮 → 回到技师选择页。选完时间后自动回到服务列表,该服务入购物车
4 服务列表 继续添加服务 / 打开购物车弹框 可为当前 Guest 继续添加,或添加新 Guest
5 购物车弹框 查看/编辑/移除项目,添加 Guest 按 Guest 分组显示。编辑 → 重走技师→时间流程
6 验证/确认页 填信息 + 手机验证 + 填 notes + 通知偏好 Primary Guest 做手机验证。Notes 留给技师。通知偏好选 SMS/Email
7 成功页 查看确认信息 显示所有 Guest 的预约详情

2. 购物车弹框设计

2.1 弹框结构

┌──────────────────────────────────────┐
│  购物车                          ✕   │
├──────────────────────────────────────┤
│                                      │
│  👤 Guest A (You)                    │
│  ┌────────────────────────────────┐  │
│  │ 💅 Full Nail Set               │  │
│  │ Tech: Emma · Feb 15, 10:00 AM │  │
│  │ $50 · 60 min                   │  │
│  │           [编辑]  [移除]       │  │
│  └────────────────────────────────┘  │
│  ┌────────────────────────────────┐  │
│  │ 🦶 Pedicure                    │  │
│  │ Tech: Any · Feb 15, 11:00 AM  │  │
│  │ $40 · 45 min                   │  │
│  │           [编辑]  [移除]       │  │
│  └────────────────────────────────┘  │
│                                      │
│  👤 Guest B (Alice)                  │
│  ┌────────────────────────────────┐  │
│  │ 💅 Gel Manicure                │  │
│  │ Tech: Emma · Feb 15, 11:00 AM │  │
│  │ $45 · 50 min                   │  │
│  │           [编辑]  [移除]       │  │
│  └────────────────────────────────┘  │
│                                      │
│  [+ 添加 Guest]                      │
│                                      │
├──────────────────────────────────────┤
│  3 services · 155 min                │
│  Total: $135            [结账 →]     │
└──────────────────────────────────────┘

2.2 添加 Guest 弹框

┌──────────────────────────────┐
│  添加同行客人                 │
├──────────────────────────────┤
│  姓名 *:  [________________] │
│  手机号:  [________________] │
│  (可选)                      │
│                              │
│       [取消]  [确认添加]     │
└──────────────────────────────┘

添加 Guest 后回到服务列表,当前活跃 Guest 切换为新 Guest,可为其选择服务。

3. API 调用序列

3.1 浏览阶段

步骤API参数说明
加载店铺 GET /api/public/booking/stores/{code} store code (URL) 获取店铺信息
加载服务 GET /api/public/booking/services store_id 获取服务列表(含分类)
加载技师 GET /api/public/booking/employees store_id, service_id, date 获取可用技师列表
加载时段 GET /api/public/booking/slots store_id, service_id, date, employee_id, excluded_slots 获取可用时间段(含冲突排除)

3.2 冲突检测:excluded_slots 参数

关键机制:查询时间段时,将购物车中所有 Guest(同一天)的已选项目作为 excluded_slots 传给后端,实现跨 Guest 冲突检测。

参数格式(JSON 编码后作为 query string):

[
  {
    "employee_id": "emp_001",   // 或 null(任意技师)
    "start_time": "10:00",      // HH:mm
    "duration": 60              // 分钟
  },
  {
    "employee_id": null,
    "start_time": "11:00",
    "duration": 45
  }
]

后端处理逻辑:

前端生成逻辑:遍历购物车中所有 Guest 的所有项目,筛选出与当前选择日期相同的项目:

// 伪代码
const excludedSlots = cart.allGuests
  .flatMap(guest => guest.items)
  .filter(item => item.date === selectedDate && item.time !== null)
  .map(item => ({
    employee_id: item.employee_id,  // null = any
    start_time: item.time,
    duration: item.service.duration
  }));

3.3 确认阶段

步骤API参数
发送验证码 POST /api/public/booking/send-code { phone, store_id }
确认预约 POST /api/public/booking/confirm 见下方详细结构

3.4 确认预约请求体

单 Guest(无 Group):

{
  "phone": "1234567890",
  "code": "123456",
  "store_id": "store_001",
  "guest_name": "Alice",
  "email": "alice@example.com",
  "notes": "Please use gel polish",
  "source": "customer_web",
  "notification_preferences": { "sms": true, "email": true },
  "ref": "marketing-abc",
  "sub_appointments": [
    { "service_id": "svc_1", "employee_id": "emp_1", "datetime": "2025-02-15T10:00:00" },
    { "service_id": "svc_2", "employee_id": null, "datetime": "2025-02-15T11:00:00" }
  ]
}

多 Guest(Group Appointment):

{
  "phone": "1234567890",
  "code": "123456",
  "store_id": "store_001",
  "guest_name": "Alice",
  "email": "alice@example.com",
  "notes": "We're celebrating a birthday!",
  "source": "customer_web",
  "notification_preferences": { "sms": true, "email": true },
  "ref": "marketing-abc",

  // Primary Guest 的服务
  "sub_appointments": [
    { "service_id": "svc_1", "employee_id": "emp_1", "datetime": "2025-02-15T10:00:00" }
  ],

  // Companion Guests
  "companion_appointments": [
    {
      "guest_name": "Bob",
      "guest_phone": "0987654321",    // optional
      "items": [
        { "service_id": "svc_2", "employee_id": "emp_2", "datetime": "2025-02-15T10:00:00" }
      ]
    },
    {
      "guest_name": "Charlie",
      "items": [
        { "service_id": "svc_1", "employee_id": null, "datetime": "2025-02-15T10:30:00" }
      ]
    }
  ]
}

4. 共享服务层架构

核心设计决策:抽取 cart_appointment.js 中的 Group Booking 逻辑为共享服务,两个端点调用同一函数,结果 100% 一致。
flowchart TD subgraph "共享服务层 (NEW)" S[bookingService.js
createGroupBooking] S1[创建 appointment] S2[创建 sub_appointments] S3[创建 appointment_group] S4[添加 group_members] S5[更新 guest_relationships] S --> S1 --> S2 --> S3 --> S4 --> S5 end subgraph "员工端 (已有)" A1[/api/shopping-cart/
create-appointment] A1_AUTH[员工 Token 认证] A1_AUTH --> A1 A1 --> S end subgraph "公开预约端 (扩展)" B1[/api/public/booking/
confirm] B1_AUTH[手机验证码认证] B1_AUTH --> B1 B1 --> S end style S fill:#d4edda,stroke:#28a745 style A1_AUTH fill:#fff3cd style B1_AUTH fill:#cce5ff

4.1 共享服务接口设计

// backend/services/booking/bookingService.js (NEW)

/**
 * 创建预约(支持单 Guest 和 Group)
 * 被 cart_appointment.js 和 public/booking.js 共同调用
 */
async function createBooking(client, {
  // 必需
  tenantSchema,
  storeId,
  primaryGuestId,
  startDatetime,
  subAppointments,      // [{ service_id, employee_id, datetime }]
  source,               // 'customer_web' | 'customer_mobile' | 'walk_in' | ...
  createdBy,            // guest_self_service | employee_id

  // 可选
  notes,
  companionAppointments,  // [{ guest_id, guest_name, guest_phone, items }]
  notificationPreferences,
  trackingRef,
}) {
  // 1. 创建 primary appointment + sub_appointments
  // 2. 如果有 companion_appointments:
  //    a. 查找或创建 companion guest 记录
  //    b. 为每个 companion 创建 appointment + sub_appointments
  //    c. 创建 appointment_group
  //    d. 添加所有 group_members
  //    e. 更新 guest_relationships
  // 3. 返回 { appointmentId, groupCode?, allAppointmentIds }
}

4.2 文件变更清单

文件状态说明
backend/services/booking/bookingService.js NEW 共享服务层:createBooking()
backend/api/cart_appointment.js MODIFY 重构:调用 bookingService 代替内联逻辑
backend/api/public/booking.js MODIFY /confirm 端点支持 companion_appointments,调用 bookingService

5. 前端状态管理

5.1 Booking Store 重构

// 新的购物车结构(按 Guest 分组)
interface GuestCart {
  id: string;              // 唯一标识
  name: string;            // Guest 姓名
  phone?: string;          // Guest 手机号(optional)
  isPrimary: boolean;      // 是否为主 Guest
  items: CartItem[];       // 该 Guest 的服务项目
}

interface CartItem {
  id: string;              // 唯一标识
  service: Service;
  employee_id: string | null;
  employee_name: string | null;
  date: string | null;     // YYYY-MM-DD
  time: string | null;     // HH:mm
}

interface BookingState {
  store: Store | null;
  guests: GuestCart[];          // 所有 Guest 及其购物车
  activeGuestId: string | null; // 当前正在配置的 Guest
  editingItemId: string | null; // 当前正在编辑的项目

  // Checkout 信息
  notificationPreferences: { sms: boolean; email: boolean } | null;
  notes: string | null;
  trackingRef: string | null;
}

5.2 关键操作

操作说明
addGuest(name, phone?)添加新 Guest,自动切换为 activeGuest
removeGuest(guestId)移除 Guest 及其所有项目
setActiveGuest(guestId)切换当前活跃 Guest
addItemToGuest(guestId, service)为某 Guest 添加服务(初始无技师/时间)
updateItem(itemId, updates)更新项目的技师/日期/时间
removeItem(itemId)移除某个服务项目
getAllExcludedSlots(date)获取所有 Guest 在该日期的已选时段(用于冲突检测)
canCheckout()所有 Guest 的所有项目都已配置完成

6. 前端页面变更

页面状态主要变更
/booking/[store]/page.tsx
服务列表
MODIFY • 改为单选(点击服务 → 进入技师选择)
• 底部购物车指示栏(数量、总价、打开弹框)
• 显示当前活跃 Guest 标识
• 购物车弹框组件(查看/编辑/移除/添加 Guest)
/booking/[store]/employee/page.tsx
技师选择
MODIFY • 返回按钮 → 服务列表
• 支持编辑模式(editingItemId)
/booking/[store]/time/page.tsx
时间选择
MODIFY • 返回按钮 → 技师选择页
• excluded_slots 取自所有 Guest 的所有项目
• 选完时间后自动回到服务列表(非 verify)
/booking/[store]/verify/page.tsx
验证确认
MODIFY • 添加 notes 输入框
• 订单摘要按 Guest 分组显示
• 提交时构建 companion_appointments
/booking/[store]/success/page.tsx
成功页
MODIFY • 显示所有 Guest 的预约详情
/booking/[store]/cart/page.tsx 删除 不再需要独立购物车页面,改用弹框

7. 已有基础设施(可直接复用)

组件位置复用方式
appointment_groups 表 Migration 024 直接使用,无需修改
appointment_group_members 表 Migration 024 直接使用,无需修改
guest_relationships 表 Migration 024 直接使用,Group 创建后自动更新
Group 创建逻辑 cart_appointment.js:620-650 抽取到 bookingService.js
冲突检测 appointments.js:2261-2339 已有 checkMultiServiceConflicts()
excluded_slots API public/booking.js /slots 已支持 excluded_slots 参数
Companion 处理 cart_appointment.js 抽取到 bookingService.js
Relationship 更新 guest-relationships.js 直接调用 updateVisitCount()

8. 与 Mobile App 流程对比

维度Guest Mobile AppWeb 重构后差异原因
服务选择 单选,逐个配置 单选,逐个配置 ✅ 对齐
购物车 独立页面 弹框 Web 端交互更适合弹框
冲突检测 excluded_slots + 前端校验 excluded_slots + 前端校验 ✅ 对齐
Group Appointment 暂未实现 支持多 Guest Web 端先行实现
Notes Checkout 时填写 Checkout 时填写 ✅ 对齐
通知偏好 无(APP 推送) SMS / Email 选择 Web 特有,无 push notification
验证方式 手机验证码 手机验证码 ✅ 对齐