Web 预约与签到模块 — CUJ 关键用户旅程

Public Booking, Checkin & Terminal | 更新时间: 2026-02-09

TOUCHES (Pages - 19):
booking/[store]/page.tsx (服务选择), booking/[store]/employee/page.tsx (技师选择), booking/[store]/time/page.tsx (时间选择), booking/[store]/verify/page.tsx (OTP验证+确认), booking/[store]/success/page.tsx (成功页), booking/my-appointments/page.tsx (我的预约), booking/test/page.tsx (测试页), admin/checkin/page.tsx (签到管理), admin/checkin/config/page.tsx (签到配置), admin/checkin/queue/page.tsx (排队管理), admin/checkin/stats/page.tsx (签到统计), checkin/[store_code]/page.tsx (公共签到), checkin/[store_code]/success/page.tsx (签到成功), terminal/[store_code]/page.tsx (终端欢迎页), terminal/[store_code]/queue/page.tsx (终端排队), terminal/[store_code]/select/page.tsx (终端选服务), terminal/[store_code]/confirm/page.tsx (终端确认), terminal/[store_code]/success/page.tsx (终端成功), terminal/[store_code]/walkin/page.tsx (终端Walk-in)

TOUCHES (Components - 9):
Booking/ServiceSelector.tsx, Booking/TechnicianSelector.tsx, Booking/TimeSlotPicker.tsx, Booking/WalkInModal.tsx, Booking/WalkInButton.tsx, Booking/GuestSearch.tsx, Booking/EditAppointmentForm.tsx, Booking/AppointmentForm.tsx, Booking/index.tsx

TOUCHES (Stores & Hooks):
stores/booking-store.ts (购物车状态, SessionStorage), stores/guest-auth-store.ts (客户认证, LocalStorage), hooks/api/useBookingApi.ts (API 请求封装)

TOUCHES (Backend):
backend/api/public/booking.js (公共预约 API), backend/api/booking-config.js (预约配置), backend/api/checkin-config.js (签到配置), backend/api/guest-auth.js (客户认证 060)

架构概览

在线预约系统组件关系

flowchart TB
    subgraph "Public Booking Pages"
        P1["[store]/page.tsx\n服务选择"]
        P2["[store]/employee/page.tsx\n技师选择"]
        P3["[store]/time/page.tsx\n时间选择"]
        P4["[store]/verify/page.tsx\nOTP验证+确认"]
        P5["[store]/success/page.tsx\n成功页"]
        P6["my-appointments/page.tsx\n我的预约"]
    end

    subgraph "State Management"
        BS["booking-store.ts\nZustand + SessionStorage"]
        GAS["guest-auth-store.ts\nZustand + LocalStorage"]
    end

    subgraph "Booking Components"
        SS["ServiceSelector"]
        TS["TechnicianSelector"]
        TSP["TimeSlotPicker"]
        GS["GuestSearch"]
    end

    subgraph "Backend API"
        API1["GET /public/booking/stores"]
        API2["GET /public/booking/services"]
        API3["GET /public/booking/employees"]
        API4["GET /public/booking/slots"]
        API5["POST /public/booking/confirm"]
        API6["POST /guest-auth/send-code"]
        API7["POST /guest-auth/verify-code"]
    end

    P1 --> BS
    P2 --> BS
    P3 --> BS
    P4 --> BS
    P4 --> GAS
    P5 --> BS

    P1 -.-> SS
    P2 -.-> TS
    P3 -.-> TSP

    P1 --> API2
    P2 --> API3
    P3 --> API4
    P4 --> API6
    P4 --> API7
    P4 --> API5

购物车数据模型

字段类型说明
guestCartsGuestCart[]多客户购物车数组(Group Booking)
guestCarts[].guestIdstring客户标识
guestCarts[].isPrimaryboolean是否主客户([0]始终为true)
guestCarts[].itemsBookingItem[]该客户的服务列表
BookingItem.idstring购物车项唯一ID
BookingItem.serviceService服务对象(名称、价格、时长)
BookingItem.employee_idstring | null技师ID,'any' 表示任意技师
BookingItem.datestring | nullYYYY-MM-DD,未选时为null
BookingItem.timestring | nullHH:MM,未选时为null

预约来源追踪

Source说明入口
customer_webWeb 在线预约/booking/[store]
customer_mobileGuest App 预约Flutter Guest App
kiosk门店自助终端/terminal/[store_code]
walk_in前台 Walk-in 登记WalkInModal
admin后台手动创建QuickBookingForm

目录

CUJ 总览与优先级矩阵

CUJ优先级描述触发点业务价值E2E 状态
CUJ-G1 P0 公共在线预约 — 单服务 客户访问 /booking/[store] 核心营收 — 在线获客 已覆盖
CUJ-G2 P0 公共在线预约 — 多服务购物车 客户添加多个服务到购物车 提升客单价 已覆盖
CUJ-G3 P1 Group Booking(多人预约) 客户为同伴添加服务 团体客户获客 部分覆盖
CUJ-G4 P0 客户 OTP 身份验证 进入验证页 /verify 客户身份识别 + 防滥用 已覆盖
CUJ-G5 P0 自助终端 — Check In 客户在门店终端签到 前台效率 — 自助签到 已覆盖
CUJ-G6 P0 自助终端 — Book Appointment Walk-in 客户自助预约 减少前台工作 已覆盖
CUJ-G7 P1 我的预约(客户端) 客户查看/取消预约 自助服务,减少爽约 部分覆盖
CUJ-G8 P1 签到管理(后台) 管理员配置签到+查看统计 自定义签到体验 已覆盖
CUJ-G9 P1 Walk-in 快速登记 前台在看板打开 WalkInModal 不丢失 Walk-in 客户 已覆盖

CUJ-G1: 公共在线预约 — 单服务

P0 在线获客 — 客户通过 /booking/[store] 自助预约单个服务

用户流程

flowchart TD
    A["访问 /booking/store001\n服务列表页"] --> B["浏览服务分类\n选择一个服务"]
    B --> C["添加到购物车\nautomatic navigate"]
    C --> D["/employee 选择技师\nAny Available 或指定"]
    D --> E["/time 选择日期时间\n30天内可选"]
    E --> F{"购物车完整?\nisAllComplete()"}
    F -->|"是"| G["点击 Proceed to Booking"]
    F -->|"否"| B
    G --> H["/verify 身份验证\n见 CUJ-G4"]
    H --> I["确认预约\n填写备注+通知偏好"]
    I --> J["POST /public/booking/confirm"]
    J --> K{成功?}
    K -->|"是"| L["/success 成功页\n显示确认号"]
    K -->|"否"| M["错误提示\n重试"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style L fill:#4CAF50,stroke:#2E7D32,color:#fff
    style M fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 G1.1: 完整单服务预约 — 指定技师

Given 客户访问 /en/qqnails/booking/store001
When 选择 "Gel Manicure" ($45, 60min)
  And 在 /employee 页选择技师 "Alice"
  And 在 /time 页选择明天 10:00 AM
  And 购物车显示完整,点击 Proceed
  And 完成 OTP 验证(见 G4)
  And 勾选 SMS 通知,点击确认
Then 跳转到成功页,显示确认号
  And 预约状态为 pending,source = customer_web

测试: web-booking.spec.ts

场景 G1.2: 选择"任意技师" — 系统自动分配

Given 客户选择了 "Pedicure"
When 在 /employee 页选择 "Any Available"
Then employee_id 设为 'any'
  And /time 页显示所有可用技师的合并时段
When 选择时间并确认预约
Then 后端自动分配最优技师

测试: web-booking.spec.ts

场景 G1.3: 指定技师后只显示该技师时段

Given 客户选择了 "Gel Manicure" 和技师 "Alice"
When 进入 /time 页
Then 只显示 Alice 的可用时段
  And 已被占用的时段不可点击(灰色)
  And 日期选择器显示未来30天

场景 G1.4: 深夜访问 — 自动从明天开始

Given 当前时间是 22:30(东部时间)
When 客户进入 /time 页
Then 日期选择器默认显示明天
  And 今天不可选(已过营业时间)

场景 G1.5: 返回修改 — 自动清理未完成项

Given 客户在 /employee 页(已添加服务到购物车)
When 点击返回(浏览器后退)
Then 未完成的购物车项被自动移除
  And 回到服务选择页

CUJ-G2: 公共在线预约 — 多服务购物车

P0 提升客单价 — 客户可在一次预约中选择多个服务

用户流程

flowchart TD
    A["服务列表页"] --> B["选择第1个服务"]
    B --> C["配置技师+时间"]
    C --> D["返回服务列表\n购物车底栏显示1项"]
    D --> E["选择第2个服务"]
    E --> F["配置技师+时间\n排除已占用时段"]
    F --> G["返回服务列表\n购物车底栏显示2项"]
    G --> H{"继续添加?"}
    H -->|"是"| I["选择第N个服务"]
    H -->|"否"| J["点击购物车\n查看所有项"]
    J --> K["确认全部完整"]
    K --> L["Proceed to Booking"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style L fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 G2.1: 添加多个服务到购物车

Given 客户已配置完第1个服务 "Gel Manicure" (Alice, 10:00)
When 回到服务列表选择 "Pedicure"
  And 选择 "Any Available"
  And 选择 11:00 AM
Then 购物车底栏显示 "2 services | $85"
  And 两个服务各自有独立的 employee/date/time

测试: web-booking.spec.ts

场景 G2.2: 时段冲突检测 — Excluded Slots

Given 购物车中已有 "Gel Manicure" 指定 Alice 10:00-11:00
When 添加第2个服务,也指定 Alice
  And 进入 /time 页
Then 10:00-11:00 显示为不可用(excluded)
  And getExcludedSlots() 返回已被购物车占用的时段
Excluded Slots 算法: 当选择"任意技师"时,冲突检测更复杂:
可用名额 = 未被特定占用的技师数 - "任意技师"购物车项目数。
只有可用名额 ≤ 0 时才标记为 excluded。

场景 G2.3: 编辑购物车中已有的服务

Given 购物车中有2个服务
When 打开 CartModal 点击第1个服务的编辑按钮
Then 导航到 /employee?editItem=[id]
  And 显示当前已选的技师为高亮
When 更换技师并选择新时间
Then 购物车中该项被更新

场景 G2.4: 删除购物车中的服务

Given 购物车中有3个服务
When 在 CartModal 中删除第2个服务
Then 购物车显示剩余2个服务
  And 总价重新计算

场景 G2.5: 提交时生成 sub_appointments

Given 购物车有 "Gel Manicure" (Alice, 10:00) 和 "Pedicure" (Any, 11:00)
When 确认预约
Then POST body 包含 sub_appointments 数组
  And 共享 date/time 取自主客户第一个 item
  And 后端创建单个 appointment + 多个 appointment_services

CUJ-G3: 公共在线预约 — Group Booking

P1 团体客户 — 主客户为同伴也预约服务

用户流程

flowchart TD
    A["主客户配置完自己的服务"] --> B["点击 Add Guest"]
    B --> C["输入同伴姓名\n可选手机号"]
    C --> D["为同伴选择服务"]
    D --> E["配置技师+时间"]
    E --> F{"继续添加同伴?"}
    F -->|"是"| B
    F -->|"否"| G["全部配置完成\nProceed to Booking"]
    G --> H["确认提交"]
    H --> I["后端创建 companion_appointments\n生成 group_code"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style I fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 G3.1: 为同伴预约服务

Given 主客户 Jane 已选好自己的 "Gel Manicure"
When 点击 "Add Guest" 添加同伴 "Mary"
  And 为 Mary 选择 "Pedicure" 和 "Any Available"
  And 选择同一天同一时间
  And 确认预约
Then 后端创建 companion_appointments
  And 生成共享的 group_code(如 GROUP-ABC123)
  And 成功页显示 group code

场景 G3.2: Group Booking 数据结构

Given 购物车有主客户(1服务) + 1个同伴(1服务)
When 提交到 POST /public/booking/confirm
Then 请求体包含:
  And sub_appointments[] — 主客户的服务列表
  And companion_appointments[] — 同伴的 {guest_name, phone?, items[]}
  And 所有预约共享 group_code

CUJ-G4: 客户身份验证 — OTP

P0 身份识别 — 060-guest-auth-redesign: 手机/邮箱 OTP 验证

用户流程

flowchart TD
    A["进入 /verify 页\n购物车已完整"] --> B["Step 1: 身份验证"]
    B --> C{"选择验证方式"}
    C -->|"手机"| D["输入手机号\nSend Code"]
    C -->|"邮箱"| E["输入邮箱\nSend Code"]
    D --> F["POST /guest-auth/send-code\n发送6位OTP"]
    E --> F
    F --> G["输入验证码\n+ 输入姓名"]
    G --> H["POST /guest-auth/verify-code"]
    H --> I{验证成功?}
    I -->|"是"| J["获得 JWT tokens\n存入 guest-auth-store"]
    I -->|"否"| K["错误提示\n可重发验证码"]
    K --> F
    J --> L["Step 2: 确认预约"]
    L --> M["选择通知偏好\nSMS / Email"]
    M --> N["可选: 填写邮箱\n可选: 备注"]
    N --> O["点击确认预约"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style J fill:#4CAF50,stroke:#2E7D32,color:#fff
    style K fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 G4.1: 手机号 OTP 验证 — 新客户

Given 客户在 /verify 页
When 选择"手机验证"
  And 输入手机号 "+12125551234"
  And 点击 Send Code
Then POST /guest-auth/send-code 发送 SMS
When 输入正确的6位验证码 + 姓名 "Jane"
Then POST /guest-auth/verify-code 成功
  And 自动创建 guest 记录(新手机号)
  And 返回 JWT access_token + refresh_token
  And 进入 Step 2 确认页

测试: web-booking.spec.ts

场景 G4.2: 邮箱 OTP 验证 — 已有客户

Given 客户选择"邮箱验证"
When 输入已注册的邮箱 "jane@example.com"
  And 收到验证码并输入
Then 验证成功,匹配到已有 guest 记录
  And 不创建新 guest,复用已有记录

场景 G4.3: 验证码过期/错误

Given 客户已发送验证码
When 输入错误的验证码
Then 显示"验证码错误"提示
  And 可以重新输入
When 验证码过期(超时)
Then 显示"验证码已过期"
  And 提供"重新发送"按钮

场景 G4.4: 通知偏好设置 (014)

Given 客户已通过 OTP 验证,在 Step 2
When 勾选 "SMS notifications" 和 "Email notifications"
Then 邮箱输入框变为必填
When 填写邮箱并确认
Then notification_preferences: {sms: true, email: true} 随预约提交
  And 至少选择一种通知方式(否则按钮不可点击)

CUJ-G5: 自助终端 — Check In

P0 自助签到 — 有预约的客户通过门店终端签到

用户流程

flowchart TD
    A["终端欢迎页\n/terminal/store_code"] --> B["点击 Check In"]
    B --> C["输入手机号\n/checkin/store_code"]
    C --> D{"找到今日预约?"}
    D -->|"是"| E["显示今日预约列表"]
    E --> F["选择要签到的预约"]
    F --> G["状态变为 checked_in"]
    G --> H["成功页\n/checkin/store_code/success"]
    D -->|"否"| I["提示: 未找到预约"]
    I --> J["建议预约或 Walk-in"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style H fill:#4CAF50,stroke:#2E7D32,color:#fff
    style I fill:#FF9800,stroke:#E65100,color:#fff

BDD 场景

场景 G5.1: 有预约客户签到

Given 客户 Jane 有今天 10:00 AM 的预约
When 在终端输入手机号 "2125551234"
Then 显示 Jane 的今日预约
When 点击"签到"
Then 预约状态变为 checked_in
  And 显示成功页: "Jane, 您已签到"
  And 客户加入排队队列

测试: terminal-checkin.spec.ts

场景 G5.2: 未找到预约

Given 手机号 "2125559999" 今天没有预约
When 在终端输入该手机号
Then 提示"未找到今日预约"
  And 显示两个选项: "预约" 或 "Walk-in"

CUJ-G6: 自助终端 — Book Appointment

P0 自助下单 — Walk-in 客户通过终端预约+自动签到

用户流程

flowchart TD
    A["终端欢迎页"] --> B["点击 Book Appointment"]
    B --> C["选择服务\n/terminal/store/select"]
    C --> D["选择技师"]
    D --> E["选择时间(仅当天)"]
    E --> F["确认页\n/terminal/store/confirm"]
    F --> G["输入姓名(必填)\n手机号(可跳过)"]
    G --> H["点击确认"]
    H --> I["创建预约 + 自动签到\nsource: kiosk"]
    I --> J["成功页\n/terminal/store/success"]

    style A fill:#2196F3,stroke:#1565C0,color:#fff
    style J fill:#4CAF50,stroke:#2E7D32,color:#fff
Kiosk 设计决策:

BDD 场景

场景 G6.1: Walk-in 终端预约 — 提供手机号

Given 客户在终端点击 "Book Appointment"
When 选择 "Pedicure"、"任意技师"、当天下一个可用时段
  And 输入姓名 "Walk In Guest" 和手机号 "2125559999"
  And 确认
Then 预约创建并自动签到(状态 checked_in)
  And 按手机号查找/创建 guest 记录
  And source = kiosk

测试: kiosk-booking.spec.ts

场景 G6.2: Walk-in 终端预约 — 跳过手机号

Given 客户在终端确认页
When 输入姓名 "Anonymous"
  And 点击"跳过手机号"
  And 确认
Then 创建匿名 guest(source: 'kiosk',无手机号)
  And appointments.guest_id NOT NULL(匿名也创建 guest 记录)
  And 预约创建并自动签到

测试: kiosk-booking.spec.ts

CUJ-G7: 我的预约(客户端)

P1 自助服务 — 已验证客户查看和管理自己的预约

BDD 场景

场景 G7.1: 查看我的预约列表

Given 客户已通过 OTP 验证(有有效 JWT)
When 导航到 /booking/my-appointments
Then 显示该客户关联的所有预约
  And 按时间倒序排列
  And 显示状态标签(pending / confirmed / cancelled 等)

测试: 部分覆盖

场景 G7.2: 未认证 — 需要先验证

Given 客户未登录(无 JWT)
When 访问 /booking/my-appointments
Then 提示需要先验证身份
  And 引导到 OTP 验证流程

CUJ-G8: 签到管理(后台)

P1 后台配置 — 管理员配置签到流程、管理排队、查看统计

BDD 场景

场景 G8.1: 配置签到选项

Given 用户已登录并拥有对应功能所需权限与范围(scope),在 /admin/checkin/config
When 启用"允许 Walk-in 预约"和"要求手机号"
  And 保存
Then 终端/Kiosk 行为相应更新

测试: full-flow/08-checkin.spec.ts

场景 G8.2: 管理签到排队

Given 用户已登录并拥有对应功能所需权限与范围(scope),在 /admin/checkin/queue
When 有3个客户处于 checked_in 状态
Then 按签到时间排序显示排队列表
  And 可以手动调整排队顺序
  And 可以将客户标记为 in_service

场景 G8.3: 查看签到统计

Given 用户已登录并拥有对应功能所需权限与范围(scope),在 /admin/checkin/stats
When 页面加载
Then 显示: 今日签到数、平均等待时间、Walk-in 占比
  And 可按日期范围筛选

CUJ-G9: Walk-in 快速登记

P1 不丢客户 — 前台通过 WalkInModal 快速登记无预约客户

BDD 场景

场景 G9.1: 前台 Walk-in 登记

Given 无预约客户到店
When 前台在看板点击 WalkInButton 打开 WalkInModal
  And 输入客户姓名和选择服务
  And 可选: 搜索已有客户(GuestSearch 组件)
  And 确认
Then 创建即时预约(状态直接 checked_in
  And source = walk_in
  And 客户加入排队队列

测试: booking/walk-in.spec.ts

场景 G9.2: Walk-in 匹配已有客户

Given 前台在 WalkInModal
When 使用 GuestSearch 搜索手机号 "2125551234"
Then 匹配到已有客户 "Jane Doe"
  And 自动填充姓名
  And Walk-in 预约关联到已有 guest_id

跨模块链接

从 G 模块关联 CUJ关系
G1/G2 在线预约CUJ-B1 创建预约在线预约 → 后台预约列表显示
G4 OTP 验证CUJ-A 认证guest-auth 独立于 employee auth
G5/G6 终端签到CUJ-B 预约状态签到 → checked_in → in_service
G9 Walk-inCUJ-E 客户管理Walk-in 创建/复用 guest 记录
G1 确认后CUJ-F 支付预约完成后进入结账

关键业务规则

防重复预约 — Advisory Locks: POST /public/booking/confirm 使用 PostgreSQL Advisory Locks(acquireMultiBookingLocks)防止并发提交导致同一时段重复预约。锁在事务结束后自动释放。
时段可用性计算:
状态值:
Session Storage 持久化: booking-store 使用 SessionStorage(关闭标签页即清除),guest-auth-store 使用 LocalStorage(跨会话保持登录)。这意味着:
营销追踪 (047): URL 参数 ?ref=campaign_code 会被 booking-store 捕获为 trackingRef,随预约提交到后端,用于营销实验转化追踪。