客人端 App — CUJ 关键用户旅程

关键用户旅程 - 用户流程 - BDD 场景 - E2E 测试映射

spec: 070-design-system-tokens / US4 | 更新时间: 2026-02-07

目录

CUJ 总览与优先级矩阵

CUJ 优先级 描述 触发点 业务价值 E2E 测试状态
CUJ-1 P0 新用户单服务预约 打开 App / 营销链接 核心营收路径 - 首次转化 部分覆盖
CUJ-2 P0 老用户多服务购物车预约 已登录用户浏览服务 提高客单价 - 多项目结账 部分覆盖
CUJ-3 P0 深度链接预约 营销链接 /booking/:store?ref=xxx 营销活动转化追踪 缺失
CUJ-4 P1 预约管理 用户点击"预约"标签页 减少爽约,支持自助服务 部分覆盖
CUJ-5 P1 OTP 验证码登录 结账时 / 用户点击"个人中心" 用户获取,会员转化 需重构
CUJ-6 P2 账户与个人资料管理 用户点击"个人中心"标签页(已登录) 留存,个性化 已覆盖
CUJ-7 P2 门店发现与切换 用户想要查找/切换门店 多门店覆盖 已覆盖

E2E 覆盖状态

测试文件 覆盖的 CUJ 测试场景
complete_booking_test.dart CUJ-1 (部分) App 启动、服务浏览、标签页导航
store_selection_test.dart CUJ-1, CUJ-7 门店信息展示、营业时间、联系方式
service_selection_test.dart CUJ-1, CUJ-2 服务列表、卡片、图片、加载状态
employee_selection_test.dart CUJ-1, CUJ-2 "任意技师"选项、技师卡片
time_slot_test.dart CUJ-1, CUJ-2 日历、时间网格、时段选择
cart_flow_test.dart CUJ-2 (部分) 购物车增删改查、角标、持久化、总价
appointment_list_test.dart CUJ-4 (部分) 列表、空状态、刷新、筛选
phone_login_test.dart CUJ-5 (旧体系) 手机号校验、验证码(使用旧 booking API,需迁移到新 OTP)
email_login_test.dart CUJ-5 (旧体系) 邮箱+密码登录(需迁移到 OTP)
register_test.dart CUJ-5 (旧体系) 注册表单校验(需迁移到 OTP)
profile_test.dart CUJ-6 个人资料展示、编辑、退出登录
settings_test.dart CUJ-6 语言切换、通知设置

覆盖缺口汇总

缺口CUJ缺失内容状态
结账登录流程新增 CUJ-1, CUJ-2 购物车 → 结账时触发 OTP 登录(手机号/邮箱+验证码),Flutter 端尚未适配新 OTP 体系 Flutter 需重构 LoginCubit
定金支付流程新增 CUJ-1, CUJ-2 门店设有定金政策时:显示金额 + 退订规则 → 跳转 CodePay Hosted Checkout WebView → 支付完成回调 后端 spec 已定义,Flutter 未实现
结账完整流程 CUJ-1, CUJ-2 购物车 → 结账 → 登录 → 定金(可选) → 成功页面,端到端完整路径未测试 缺失
深度链接入口 CUJ-3 没有测试 /booking/:store?ref=xxx 深度链接处理 缺失
预约详情 CUJ-4 详情页(TODO 占位符)、取消预约、改期、退订规则展示 AppointmentDetailPage 未实现
OTP 登录体系新增 CUJ-5 Flutter 仍使用旧 email+password LoginCubit,需迁移到统一 OTP API(/api/guest-auth/send-code + /verify-code) 后端已就绪,Flutter 需适配
关键依赖说明: CUJ-1 和 CUJ-2 的结账尾部流程(OTP 登录 + 定金支付)依赖两个未完成的功能: (1) Flutter OTP 登录适配(060-guest-auth-redesign), (2) CodePay Hosted Checkout 集成(053-booking-deposit-cancellation-policy + 015-pos-payment)。 详见 CodePay RSA 认证文档

共享结账流程(CUJ-1 & CUJ-2 共用尾部)

CUJ-1(单服务)和 CUJ-2(多服务)在加入购物车后共享相同的结账流程:

flowchart TD A["购物车页面"] --> B["点击结账"] B --> C{"用户已登录?"} C -->|"是"| F["订单确认页"] C -->|"否"| D["OTP 登录页"] D --> D1["输入手机号或邮箱"] D1 --> D2["发送验证码"] D2 --> D3["输入 6 位验证码"] D3 --> D4{"验证成功?"} D4 -->|"是"| F D4 -->|"否"| D5["错误提示 + 重试"] D5 --> D3 F --> G{"门店有定金政策?"} G -->|"否"| K["提交预约"] G -->|"是"| H["显示定金金额 + 退订规则"] H --> I["跳转 CodePay 支付页面 (WebView)"] I --> I1{"支付结果?"} I1 -->|"成功"| K I1 -->|"失败/取消"| I2["返回订单确认页 + 提示"] I2 --> H K --> L{"提交成功?"} L -->|"是"| M["预约成功页"] L -->|"否"| N["报错 + 重试"] M --> O["查看预约 / 返回首页"] style A fill:#2196F3,stroke:#1565C0,color:#fff style D fill:#FF9800,stroke:#E65100,color:#fff style I fill:#9C27B0,stroke:#6A1B9A,color:#fff style M fill:#4CAF50,stroke:#2E7D32,color:#fff style N fill:#f44336,stroke:#c62828,color:#fff

共享结账 BDD 场景

场景 S.1: 未登录用户结账触发 OTP 登录

Given 用户未登录,购物车中有 1 个服务
When 用户点击"结账"
Then 跳转到 OTP 登录页面
When 用户输入手机号 "2125551234"
  And 点击"发送验证码"
Then 调用 POST /api/guest-auth/send-code
  And 出现 6 位验证码输入框
When 用户输入正确验证码
Then 调用 POST /api/guest-auth/verify-code
  And 用户以已识别客人身份登录
  And 跳转到订单确认页

测试: checkout_otp_login_test.dart (新增)

场景 S.2: 邮箱 OTP 登录

Given 用户未登录,在 OTP 登录页面
When 用户切换到"邮箱"标签
  And 输入邮箱 "guest@example.com"
  And 点击"发送验证码"
Then 调用 POST /api/guest-auth/send-code(channel=email)
  And 出现验证码输入框
When 用户输入正确验证码
Then 登录成功并跳转到订单确认页

测试: checkout_otp_login_test.dart (新增)

场景 S.3: 已登录用户直接进入订单确认

Given 用户已登录
  And 购物车中有项目
When 用户点击"结账"
Then 直接跳转到订单确认页(跳过 OTP 登录)

测试: checkout_logged_in_test.dart (新增)

场景 S.4: 定金支付 - 门店有定金政策

Given 用户已登录,在订单确认页
  And 门店设有定金政策(30% 定金,24 小时免费取消)
Then 页面显示定金金额(如 $19.14)
  And 显示退订规则: "24 小时前免费取消,迟到取消扣除定金"
When 用户点击"支付定金"
Then 打开 CodePay Hosted Checkout 页面(WebView)
When 用户在 CodePay 页面完成支付
Then 后端收到 Webhook 确认支付成功
  And WebView 关闭,显示预约成功页

测试: deposit_payment_test.dart (新增)

场景 S.5: 定金支付失败/取消

Given 用户在 CodePay 支付页面
When 支付失败或用户取消支付
Then WebView 关闭
  And 返回订单确认页
  And 显示"支付未完成,请重试"提示
  And 预约未被创建

测试: deposit_payment_test.dart (新增)

场景 S.6: 无定金政策 - 直接预约

Given 用户已登录,在订单确认页
  And 门店无定金政策
When 用户点击"确认预约"
Then 直接提交预约(无需支付)
  And 显示预约成功页

测试: checkout_no_deposit_test.dart (新增)

CUJ-1: 新用户单服务预约

P0 核心营收路径 - 新用户/匿名用户预约单个服务

用户流程

flowchart TD A["启动 App"] --> B["首页 / 预约首页"] B --> C{"门店已预选?"} C -->|"是"| D["选择服务"] C -->|"否"| E["选择门店"] E --> D D --> F["选择技师"] F --> G{"指定技师?"} G -->|"是"| H["选择具体技师"] G -->|"任意技师"| I["跳过"] H --> J["选择时段"] I --> J J --> K["选择日期 + 时间"] K --> L["加入购物车"] L --> M["购物车页面(1 项)"] M --> N["点击结账"] N --> CHECKOUT["共享结账流程"] style A fill:#2196F3,stroke:#1565C0,color:#fff style L fill:#FF9800,stroke:#E65100,color:#fff style CHECKOUT fill:#9C27B0,stroke:#6A1B9A,color:#fff
流程变更说明: 选择服务/技师/时间后,项目先加入购物车,再从购物车进入结账。 结账时触发 OTP 登录(如未登录),之后根据门店政策决定是否需要支付定金。 完整结账流程见 共享结账流程

BDD 场景

场景 1.1: 完整单服务预约 - 正常路径

Given 用户首次打开 App(匿名状态)
  And 默认门店已预配置
When 用户选择服务 "Gel Manicure"
  And 选择"任意技师"
  And 选择今天第一个可用时段
  And 点击"加入购物车"
Then 购物车角标显示 "1"
When 用户打开购物车并点击"结账"
Then 跳转到 OTP 登录页(因为未登录)
When 用户输入手机号并验证
  And 在订单确认页点击"确认预约"
Then 显示预约成功页面,包含预约 ID
  And 购物车被清空

测试: booking_e2e_happy_path_test.dart (新增)

场景 1.2: 选择指定技师

Given 用户在技师选择页面
When 用户点击某个技师卡片
Then 该技师被高亮选中
  And "继续"按钮变为可用
When 用户点击"继续"
Then 时段选择页面只显示该技师的可用时段

测试: employee_selection_test.dart (已有 - 已覆盖)

场景 1.3: 加入购物车后继续浏览

Given 用户已将一个服务加入购物车
When 用户点击"继续浏览"而非"去购物车"
Then 返回服务列表页面
  And 购物车角标显示 "1"
  And 用户可以添加更多服务

测试: cart_flow_test.dart (已有 - 部分覆盖)

场景 1.4: 时段不可用(竞态条件)

Given 用户购物车中有一个服务+时段
  And 另一个用户同时预约了相同时段
When 用户提交预约
Then 显示错误信息"该时段已不可用"
  And 用户可以返回选择其他时间

测试: booking_e2e_happy_path_test.dart (新增 - 异常场景)

CUJ-2: 老用户多服务购物车预约

P0 提高客单价 - 用户通过购物车预约多个服务

用户流程

flowchart TD A["首页 - 浏览服务"] --> B["选择服务1 + 技师 + 时间"] B --> C["加入购物车"] C --> D["继续浏览"] D --> E["选择服务2 + 技师 + 时间"] E --> F["加入购物车"] F --> G["购物车角标显示 2"] G --> H["打开购物车页面"] H --> I{"编辑项目?"} I -->|"是"| J["修改技师/时间"] I -->|"否"| K["点击结账"] J --> K K --> CHECKOUT["共享结账流程"] style A fill:#2196F3,stroke:#1565C0,color:#fff style C fill:#FF9800,stroke:#E65100,color:#fff style F fill:#FF9800,stroke:#E65100,color:#fff style CHECKOUT fill:#9C27B0,stroke:#6A1B9A,color:#fff
与 CUJ-1 的区别: CUJ-2 的核心差异在于用户添加多个服务到购物车, 每个服务可以独立配置技师和时间。结账流程与 CUJ-1 完全相同,见 共享结账流程

BDD 场景

场景 2.1: 添加多个服务到购物车

Given 用户在预约首页
When 用户选择 "Gel Manicure" + 任意技师 + 10:00 AM,加入购物车
  And 继续选择 "Pedicure" + Alice + 11:00 AM,加入购物车
Then 购物车角标显示 "2"
When 用户打开购物车页面
Then 两个项目都列出,各自显示技师、时间、价格和时长
  And 总价等于两项服务的价格之和

测试: cart_flow_test.dart (已有 - 已覆盖)

场景 2.2: 购物车中修改项目配置

Given 购物车包含 2 个服务
When 用户点击项目1的"修改"
  And 更换技师为 "Bob"
  And 更换时间为 "2:00 PM"
Then 项目1更新为 "Bob, 2:00 PM"
  And 项目2不受影响

测试: cart_checkout_test.dart (新增)

场景 2.3: 时段冲突检测

Given 购物车包含 "Gel Manicure" (10:00 AM, 45 分钟)
When 用户为第二个服务选择同一技师 10:15 AM
Then 时段显示为不可用(灰色)
  And 提示"该时段与购物车中的项目冲突"

测试: cart_conflict_test.dart (新增)

场景 2.4: 购物车持久化

Given 用户购物车中有项目
When App 关闭后重新打开
Then 购物车项目仍然存在,配置信息不变

测试: cart_flow_test.dart (已有 - 已覆盖)

CUJ-3: 深度链接预约

P0 营销活动转化 - 用户通过 /booking/:store?ref=xxx 打开 App

用户流程

flowchart TD A["打开链接: /booking/salon001?ref=instagram_jan"] --> B["_BookingDeepLinkHandler"] B --> C["在 BookingBloc 中设置追踪 ref"] C --> D["加载门店 salon001 数据"] D --> E{"门店存在?"} E -->|"是"| F["跳转到预约首页 - 门店已预选"] E -->|"否"| G["错误: 门店不存在"] F --> H["继续正常预约流程..."] H --> I["预约成功: booking.tracking_ref = instagram_jan"] style A fill:#9C27B0,stroke:#6A1B9A,color:#fff style F fill:#4CAF50,stroke:#2E7D32,color:#fff style G fill:#f44336,stroke:#c62828,color:#fff

BDD 场景

场景 3.1: 有效深度链接

Given 用户通过链接 /booking/salon001?ref=ig_promo 打开 App
When 深度链接被处理
Then 预约首页加载,门店 "salon001" 已预选
  And 追踪标记 "ig_promo" 存储在 BookingBloc 中
When 用户完成预约
Then 创建的预约包含 tracking_ref = "ig_promo"

测试: deep_link_booking_test.dart (新增)

场景 3.2: 无效门店深度链接

Given 用户通过链接 /booking/invalid_store 打开 App
When 深度链接处理器尝试加载门店
Then 显示错误信息"门店不存在"
  And 用户可以手动导航到门店选择页面

测试: deep_link_booking_test.dart (新增)

CUJ-4: 预约管理

P1 减少爽约 - 用户查看、取消或改期预约

用户流程

flowchart TD A["预约标签页"] --> B["查看预约列表"] B --> C{"筛选?"} C -->|"即将到来"| D["显示即将到来的预约"] C -->|"历史"| E["显示已完成/已取消的预约"] D --> F["点击预约卡片"] F --> G["预约详情页"] G --> H{"操作?"} H -->|"取消"| I["确认取消对话框"] I --> I1{"在免费取消窗口内?"} I1 -->|"是"| J["免费取消,退还定金"] I1 -->|"否"| J1["显示取消费用提示"] J1 --> J2{"用户确认?"} J2 -->|"是"| J3["取消并扣除费用"] J2 -->|"否"| G H -->|"改期"| M["选择时段"] M --> N["选择新时间"] N --> O["确认改期"] H -->|"仅查看"| P["查看详情后返回"] style A fill:#2196F3,stroke:#1565C0,color:#fff style J fill:#4CAF50,stroke:#2E7D32,color:#fff style J3 fill:#f44336,stroke:#c62828,color:#fff style O fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 4.1: 查看预约列表

Given 用户有 3 个即将到来的预约
When 用户点击"预约"标签页
Then 列表显示 3 张预约卡片
  And 每张卡片显示日期、时间、服务名称和状态

测试: appointment_list_test.dart (已有 - 已覆盖)

场景 4.2: 查看预约详情

Given 用户在预约列表页面
When 用户点击某个预约卡片
Then 详情页展示:
  - 服务名称、时长、价格
  - 技师姓名
  - 日期和时间
  - 门店名称和地址
  - 状态(已确认/待确认/已取消)
  - 定金信息(如已支付)
  - 退订规则

测试: appointment_detail_test.dart (新增 - AppointmentDetailPage 尚未实现)

场景 4.3: 免费取消预约(窗口期内)

Given 用户在预约详情页面
  And 预约在 24 小时后开始(在免费取消窗口内)
  And 已支付 $19.14 定金
When 用户点击"取消预约"
Then 对话框显示"免费取消,定金将退还"
When 用户确认取消
Then 预约状态变为"已取消"
  And 定金退还流程启动

测试: appointment_management_test.dart (新增)

场景 4.4: 迟到取消(窗口期外)

Given 用户在预约详情页面
  And 预约在 2 小时后开始(超出免费取消窗口)
When 用户点击"取消预约"
Then 对话框显示"迟到取消,定金 $19.14 将不予退还"
When 用户确认取消
Then 预约状态变为"已取消"
  And 定金被没收

测试: appointment_management_test.dart (新增)

场景 4.5: 下拉刷新

Given 用户正在查看预约列表
When 用户下拉刷新
Then 短暂出现加载指示器
  And 列表更新为服务器最新数据

测试: appointment_list_test.dart (已有 - 已覆盖)

CUJ-5: OTP 验证码登录

P1 用户获取 - 统一的手机号/邮箱 OTP 验证码登录

体系变更: 原 CUJ-5 使用邮箱+密码登录体系。后端已完成 060-guest-auth-redesign 重构, 统一为 OTP 验证码登录(/api/guest-auth/send-code + /api/guest-auth/verify-code), 支持手机号和邮箱两种渠道。
Flutter 适配状态: 尚未适配。当前 LoginCubit 仍使用旧的 email+password 体系, phone_login_page 仍调用旧的 /api/public/booking/verify-phone 端点。

用户流程

flowchart TD A["个人中心 - 未登录"] --> B{"选择方式"} B -->|"手机号"| C["输入手机号"] C --> D["发送验证码 (SMS)"] D --> E["输入 6 位验证码"] E --> F{"验证成功?"} F -->|"是"| G["登录成功 - 跳转首页"] F -->|"否"| H["错误提示 + 重试"] H --> E B -->|"邮箱"| I["输入邮箱"] I --> J["发送验证码 (Email)"] J --> K["输入 6 位验证码"] K --> L{"验证成功?"} L -->|"是"| G L -->|"否"| M["错误提示 + 重试"] M --> K style A fill:#2196F3,stroke:#1565C0,color:#fff style G fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

场景 5.1: 手机号 OTP 登录

Given 用户在登录页面
When 用户输入手机号 "2125551234"
  And 点击"发送验证码"
Then 调用 POST /api/guest-auth/send-code {contact: "2125551234"}
  And 后端自动识别为 SMS 渠道
  And 出现验证码输入框 + 60 秒倒计时
When 用户输入正确验证码
Then 调用 POST /api/guest-auth/verify-code
  And 返回 JWT token
  And 跳转首页,个人中心显示用户姓名

测试: otp_login_test.dart (新增 - 替代旧 phone_login_test.dart)

场景 5.2: 邮箱 OTP 登录

Given 用户在登录页面
When 用户切换到"邮箱"标签
  And 输入邮箱 "guest@example.com"
  And 点击"发送验证码"
Then 调用 POST /api/guest-auth/send-code {contact: "guest@example.com"}
  And 后端自动识别为 Email 渠道
  And 出现验证码输入框
When 用户输入正确验证码
Then 登录成功并跳转首页

测试: otp_login_test.dart (新增)

场景 5.3: 验证码过期/错误

Given 用户已收到验证码但超过 5 分钟
When 用户输入过期的验证码
Then 显示错误"验证码已过期,请重新发送"
  And "重新发送"按钮变为可用

测试: otp_login_test.dart (新增)

场景 5.4: 新用户自动注册

Given 用户输入的手机号在系统中不存在
When 用户完成 OTP 验证
Then 系统自动创建 guest 记录
  And 用户以新客人身份登录
  And 无需额外注册步骤

测试: otp_login_test.dart (新增)

CUJ-6: 账户与个人资料管理

P2 用户留存 - 已登录用户管理个人资料和偏好设置

用户流程

flowchart TD A["个人中心 - 已登录"] --> B["账户信息页面"] B --> C{"操作?"} C -->|"编辑资料"| D["编辑姓名 / 邮箱 / 手机号"] D --> E["保存修改"] C -->|"语言"| I["切换 中/英"] I --> J["界面立即更新"] C -->|"会员"| K["查看会员等级和积分"] C -->|"退出登录"| L["确认退出"] L --> M["跳转到首页 - 访客状态"] style A fill:#2196F3,stroke:#1565C0,color:#fff

BDD 场景

所有场景已被现有测试覆盖: profile_test.dart, settings_test.dart

CUJ-7: 门店发现与切换

P2 多门店覆盖 - 用户查找并切换门店

用户流程

flowchart TD A["预约首页"] --> B["点击门店名称 / 切换门店"] B --> C["门店选择页面"] C --> D["浏览门店列表"] D --> E["查看门店: 名称、地址、营业时间"] E --> F["选择门店"] F --> G["返回预约首页 - 已切换门店"] G --> H["服务列表更新为所选门店"] style A fill:#2196F3,stroke:#1565C0,color:#fff style H fill:#4CAF50,stroke:#2E7D32,color:#fff

BDD 场景

所有场景已被现有测试覆盖: store_selection_test.dart

待新增 E2E 测试汇总

优先级 测试文件 (新增) CUJ 测试场景 前置依赖
P0 booking_e2e_happy_path_test.dart CUJ-1 完整端到端: 选服务 -> 选技师 -> 选时间 -> 加入购物车 -> 结账 -> OTP 登录 -> 成功 OTP 登录适配
P0 checkout_otp_login_test.dart CUJ-1, CUJ-2 结账触发 OTP 登录: 手机号+验证码、邮箱+验证码、已登录跳过 OTP 登录适配
P0 deposit_payment_test.dart CUJ-1, CUJ-2 定金支付: CodePay WebView 支付、支付成功/失败、无定金策略直接预约 CodePay Hosted Checkout 集成
P0 cart_checkout_test.dart CUJ-2 购物车 -> 修改项目配置 -> 结账 -> 成功 OTP 登录适配
P0 deep_link_booking_test.dart CUJ-3 深度链接处理、门店自动选择、追踪标记
P1 otp_login_test.dart CUJ-5 独立 OTP 登录: 手机号/邮箱发验证码、验证成功/失败、新用户自动注册 OTP 登录适配
P1 appointment_management_test.dart CUJ-4 预约详情、免费取消、迟到取消、定金退还 AppointmentDetailPage 实现
P1 cart_conflict_test.dart CUJ-2 购物车时段冲突检测

Flutter 适配任务

任务 说明 影响的 CUJ 相关 Spec
OTP LoginCubit 重构 将 email+password LoginCubit 迁移到统一 OTP 体系,调用新 API /api/guest-auth/send-code + /verify-code CUJ-1, CUJ-2, CUJ-5 060-guest-auth-redesign
CodePay WebView 集成 结账时打开 CodePay Hosted Checkout WebView 进行定金支付,处理支付回调 CUJ-1, CUJ-2 053-booking-deposit / 015-pos-payment
AppointmentDetailPage 实现 预约详情页(当前 TODO 占位符),含定金信息、退订规则、取消/改期操作 CUJ-4 -

基于 spec 070-design-system-tokens / US4 生成
方法: CUJ -> 用户流程 (Mermaid) -> BDD (Given-When-Then) -> integration_test (Dart)
关联文档: CodePay RSA 认证 | CodePay 完整支付流程