支付状态恢复与韧性设计 — 2026-02-12
本文档描述的是基于 PaymentCheckoutFlow.tsx + sessionStorage 的 Cloud Mode 支付恢复方案。
当前所有卡支付均通过 POS Intent Mode(NewCheckoutFlow.tsx)处理,恢复机制已完全重新设计。
请查看最新文档:POS 支付状态机 & 防护机制(第 11 节:恢复与韧性机制)
新架构的三条恢复链路:
GET /pos-request/pending 查询后端 DB 恢复进度条SecureStorage 加密存储未同步支付,防双重扣款GET /refunds/pending 重新打开 RefundModal 时恢复等待状态当前端支付等待 UI(PaymentProgressModal)被意外关闭,或者用户导航离开支付页面时,会丢失以下关键状态:
| 丢失的状态 | 存储方式 | 丢失场景 |
|---|---|---|
paymentRequestId | React useState | 组件卸载 / 页面刷新 |
orderDetails | React useState | 组件卸载 / 页面刷新 |
paymentStatus | React useState | 组件卸载 / 页面刷新 |
| WebSocket 连接 | Socket.IO 实例 | 组件卸载 / 网络断开 |
同时,已有的 queryPaymentStatus 函数和后端 POST /api/payment/query-status 端点从未被调用过,是现成但闲置的基础设施。
flowchart TD
A["用户发起支付 (initiatePayment)"] --> B["写入 sessionStorage"]
B --> C{"支付进行中 (progress)"}
C --> D["WebSocket 实时监听"]
C --> E["轮询安全网 (5s)"]
C --> F["beforeunload 拦截"]
D --> G{"终态?"}
E --> G
G -- "completed" --> H["显示成功 + 清除 storage"]
G -- "failed/cancelled/timeout" --> I["清除 storage + 重置"]
G -- "仍在处理" --> C
J["用户刷新/重开页面"] --> K["组件挂载"]
K --> L{"sessionStorage 有待恢复数据?"}
L -- "无" --> M["正常 idle"]
L -- "有(未过期)" --> N["queryPaymentStatus 查后端"]
N -- "仍在处理" --> O["恢复上下文 + 进入 progress"]
N -- "已完成" --> P["toast 通知 + 清除"]
N -- "已失败/过期" --> Q["清除 storage"]
O --> C
五层防护机制:
| # | 机制 | 作用 | 触发条件 |
|---|---|---|---|
| 1 | sessionStorage | 持久化支付上下文,跨组件卸载/页面刷新保留 | initiatePayment 成功后 |
| 2 | 轮询回退 | 每 5 秒查后端状态,WebSocket 断连时的安全网 | stage === 'progress' |
| 3 | 自动恢复 | 组件重新挂载时检测并恢复未完成的支付流程 | 组件 mount + sessionStorage 有数据 |
| 4 | beforeunload | 阻止用户意外关闭标签页/导航离开 | stage === 'progress' |
| 5 | WS 生命周期解耦 | WebSocket 不再绑定 stage,只要 paymentRequestId 存在就连接 | !!paymentRequestId |
核心文件:frontend/web_app/src/components/Payment/PaymentCheckoutFlow.tsx
Key: payment_pending_{appointment.id}
Value: {
paymentRequestId: string, // CodePay 支付请求 ID
orderId: string, // 订单号
transactionId: string, // 交易记录 ID
totalAmount: number, // 支付金额
storeId: string, // 门店 ID
terminalId: string, // POS 终端 ID
createdAt: number // 写入时间戳 (Date.now())
}
localStorage 会跨标签页/跨会话持久存在,必须手动管理清理逻辑,增加复杂度和僵尸数据风险。
sequenceDiagram
participant User
participant UI as PaymentCheckoutFlow
participant SS as sessionStorage
participant Backend
User->>UI: 选择终端,点击发送
UI->>Backend: POST /api/payment/initiate
Backend-->>UI: { request_id: "xyz" }
UI->>SS: setItem("payment_pending_apt123", {...})
Note over UI: stage = 'progress'
alt 正常完成
Backend-->>UI: WebSocket completed
UI->>SS: removeItem("payment_pending_apt123")
else 用户刷新页面
User->>UI: 页面重新加载
UI->>SS: getItem("payment_pending_apt123")
SS-->>UI: { paymentRequestId: "xyz", ... }
UI->>Backend: POST /api/payment/query-status
Backend-->>UI: { status: "processing" }
Note over UI: 恢复 → stage = 'progress'
else 超过 10 分钟
UI->>SS: getItem → createdAt 过期
UI->>SS: removeItem (清除)
end
WebSocket 是主通道(低延迟、实时推送),轮询是安全网(覆盖 WS 断连、漏消息等边缘情况)。两者共享同一个 applyPaymentStatus 函数,状态转换逻辑完全一致。
| 参数 | 值 | 说明 |
|---|---|---|
| 轮询间隔 | 5 秒 | 平衡后端负载与状态同步延迟 |
| API 端点 | POST /api/payment/query-status | 已有基础设施,无需新增后端代码 |
| 启动条件 | stage === 'progress' && !!paymentRequestId | 仅在支付进行中时轮询 |
| 终止条件 | 到达终态 (completed/failed/cancelled/timeout) | 通过 paymentStatusRef 检查避免重复副作用 |
轮询回调通过 paymentStatusRef(React ref)检查当前状态。如果已经是终态,跳过 API 调用。这避免了以下问题:
completed → 1.5s 后 stage 变为 success → 在这 1.5s 内轮询可能也读到 completed → 如果不守卫,会触发重复的 setTimeout 和 toastpaymentStatus 加入 useEffect 依赖数组(否则每次状态变化都会重置定时器)
flowchart TD
A["组件挂载"] --> B{"recoveryAttemptedRef === appointment.id?"}
B -- "是 (已尝试过)" --> Z["跳过"]
B -- "否" --> C{"stage === 'idle'?"}
C -- "否 (已在流程中)" --> Z
C -- "是" --> D["标记为已尝试"]
D --> E["loadPaymentContext(appointment.id)"]
E --> F{"有数据?"}
F -- "无" --> Z
F -- "有" --> G{"已过期 (>10min)?"}
G -- "是" --> H["清除 sessionStorage"]
G -- "否" --> I["queryPaymentStatus(paymentRequestId)"]
I --> J{"后端返回状态"}
J -- "pending/acknowledged/processing" --> K["恢复全部上下文, stage='progress'"]
J -- "completed" --> L["toast 成功通知, 清除"]
J -- "failed/cancelled/timeout" --> M["静默清除"]
J -- "网络错误" --> M
useRef<string> 记录已尝试恢复的 appointment ID,而非简单的 boolean。当切换到不同预约时,允许对新预约重新尝试恢复。
// 当 stage === 'progress' 时注册
window.addEventListener('beforeunload', (e) => {
e.preventDefault();
e.returnValue = ''; // 触发浏览器原生提示
});
// stage 变化或组件卸载时自动移除
e.returnValue = '' 赋值仍然是触发提示的必要操作。
| 修改前 | 修改后 | |
|---|---|---|
WS enabled |
stage === 'progress' |
!!paymentRequestId |
| 影响 | stage 被意外改变 → WS 断开 → 丢消息 | 只要 paymentRequestId 存在 → WS 保持连接 |
| 恢复场景 | 恢复时先设 stage → WS 才连 → 可能错过消息 | 恢复时先设 paymentRequestId → WS 立即连接 → 不漏消息 |
原来 WebSocket 事件处理和轮询结果各自维护一套 switch-case,容易不一致。重构后提取为 applyPaymentStatus(status, message?),作为两个通道的 Single Source of Truth。
// WebSocket 事件 → applyPaymentStatus
function handlePaymentEvent(event: PaymentEvent) {
if (event.requestId !== paymentRequestId) return;
applyPaymentStatus(event.type || '', event.data?.message);
}
// 轮询结果 → applyPaymentStatus
const result = await queryPaymentStatus(paymentRequestId);
if (result.success && result.data?.status) {
applyPaymentStatus(result.data.status, result.data.message);
}
applyPaymentStatus 内部负责:
clearPaymentContext)paymentStatus 和 statusMessage| 优点 | 说明 |
|---|---|
| 零后端改动 | 完全复用已有的 POST /api/payment/query-status 端点和 queryPaymentStatus 函数 |
| 渐进增强 | sessionStorage 不可用时(SSR、隐私模式配额耗尽)graceful degradation,不影响正常流程 |
| 双通道保障 | WebSocket(实时)+ 轮询(5s)互补,单一通道故障不影响终态同步 |
| 自动清理 | sessionStorage 标签页级别 + 10 分钟过期 + 终态清除 = 三重清理机制,无僵尸数据 |
| 单一状态转换源 | applyPaymentStatus 统一处理所有状态变更,WS 和轮询不会出现行为不一致 |
| 用户体验 | 刷新后自动恢复支付监听 + beforeunload 提示 = 店员不会因误操作丢失支付状态 |
| 局限 | 影响 | 风险等级 |
|---|---|---|
| CodePay 直接操作不可知 | 如果商家直接在 CodePay 后台或终端上执行 void/refund(绕过我们的 API),我们的系统不会自动同步该状态变化。queryPaymentStatus 查的是我们自己的数据库,不是 CodePay。 |
高 |
| 跨标签页不共享 | sessionStorage 是标签页隔离的。如果用户在标签页 A 发起支付,然后在标签页 B 打开同一预约,标签页 B 不会恢复支付状态。这是有意设计(避免竞争),但可能偶尔困惑。 | 低 |
| 10 分钟硬编码过期 | 如果 CodePay 超时配置被调大(超过 10 分钟),恢复逻辑可能过早放弃。当前 CodePay 默认 5 分钟,10 分钟已足够,但如果以后配置变更需要同步更新 PAYMENT_EXPIRY_MS。 |
低 |
| 恢复时 OrderDetails 不完整 | 恢复时构造的 OrderDetails 只有 order_id 和 totalAmount,税额/折扣/明细为空。PaymentProgressModal 只用到这两个字段所以不影响,但如果未来 UI 改为在 progress 阶段显示更多信息,需要从后端重新拉取完整订单。 |
低 |
| 轮询无指数退避 | 当前固定 5 秒间隔。如果后端暂时不可达,轮询会持续以 5 秒频率重试直到终态。不会造成 DDoS(单用户单请求),但如果想更优雅可以加指数退避。 | 低 |
| 不覆盖"关闭标签页后重开"场景 | sessionStorage 在标签页关闭后自动清除。如果用户在支付进行中关闭了整个标签页,然后重新打开网站,不会触发恢复。但此时后端 webhook 已处理,DB 状态正确,只是前端不会主动弹 toast 通知。 | 低 |
这是最重要的局限,值得单独展开:
sequenceDiagram
participant Merchant as 商家 (CodePay 后台)
participant CodePay as CodePay 网关
participant Our as 我们的后端
participant DB as 我们的数据库
Note over Merchant,DB: 场景1: 通过我们的 API 操作 (可追踪)
Our->>CodePay: POST /api/entry/orderquery
CodePay-->>Our: { status: "completed" }
Our->>DB: UPDATE transactions SET status = 'completed'
Note over Merchant,DB: 场景2: 商家直接在 CodePay 操作 (不可追踪)
Merchant->>CodePay: 在 CodePay 后台直接 void
CodePay->>CodePay: 交易已作废
Note over Our,DB: 我们完全不知道这件事发生了!
Note over DB: DB 仍然显示 status = 'completed'
| 方案 | 时机 | 实现复杂度 | 效果 |
|---|---|---|---|
| 日结对账:Day-End Closeout 时批量调用 CodePay Query Order API | 短期可做 | 中等 | T+0 日末发现差异,但不实时 |
| T+1 账单下载:定时任务下载 CodePay 交易报表进行比对 | 中期 | 中等 | 覆盖面广,但有 1 天延迟 |
| Void 事件推送:联系 CodePay 注册 void/refund callback | 长期 | 低(如果 CodePay 支持) | 实时同步,但依赖第三方 |
| 文件 | 角色 |
|---|---|
| frontend/web_app/src/components/Payment/PaymentCheckoutFlow.tsx | 主逻辑 — sessionStorage / 轮询 / 恢复 / beforeunload / WS 解耦 |
| frontend/web_app/src/hooks/usePaymentApi.ts | 提供 queryPaymentStatus(已有,无修改) |
| frontend/web_app/src/hooks/usePaymentWebSocket.ts | WS 连接(无修改,enabled 参数由调用方控制) |
| frontend/web_app/src/components/Payment/PaymentProgressModal.tsx | 进度模态框(无修改,已阻止非终态关闭) |
| backend/api/payment.js:5616 | POST /api/payment/query-status 端点(已有,无修改) |
| specs/payments/015-pos-payment/spec.md | spec 文档(新增韧性章节) |
| frontend/web_app/src/i18n/locales/{en,zh}/payment.json | 新增 status.recovered 翻译键 |
| # | 场景 | 预期结果 |
|---|---|---|
| 1 | 正常支付流程(端到端) | sessionStorage 写入 → 终态清除,流程不受影响 |
| 2 | Progress 阶段关闭模态框 → 重新打开预约 | 自动恢复到 progress,toast 提示"支付会话已恢复" |
| 3 | Progress 阶段刷新页面 | 自动恢复,WebSocket + 轮询继续监听 |
| 4 | Progress 阶段尝试关闭标签页 | 浏览器弹出原生"确认离开"提示 |
| 5 | 支付完成后检查 sessionStorage | 对应 key 已被清除 |
| 6 | 手动写入 >10min 前的过期数据 | 组件忽略并清除,不触发恢复 |
| 7 | 后端不可达时的恢复尝试 | 清除 sessionStorage,不报错,不进入 progress |