Payment Status Recovery & Resilience

支付状态恢复与韧性设计 — 2026-02-12

⚠️ 本文档已过时 — Cloud Mode 已废弃

本文档描述的是基于 PaymentCheckoutFlow.tsx + sessionStorageCloud Mode 支付恢复方案。

当前所有卡支付均通过 POS Intent ModeNewCheckoutFlow.tsx)处理,恢复机制已完全重新设计。

请查看最新文档POS 支付状态机 & 防护机制(第 11 节:恢复与韧性机制)

新架构的三条恢复链路:

  1. Web 刷新恢复GET /pos-request/pending 查询后端 DB 恢复进度条
  2. syncFailed 持久化 — POS 端 SecureStorage 加密存储未同步支付,防双重扣款
  3. VOID Modal 恢复GET /refunds/pending 重新打开 RefundModal 时恢复等待状态

1. 问题背景

当前端支付等待 UI(PaymentProgressModal)被意外关闭,或者用户导航离开支付页面时,会丢失以下关键状态:

核心风险:客户已在 POS 终端完成刷卡,但前端 UI 状态丢失 → 预约详情页不显示"已付款" → 店员以为没付 → 可能要求客户再刷一次。
丢失的状态存储方式丢失场景
paymentRequestIdReact useState组件卸载 / 页面刷新
orderDetailsReact useState组件卸载 / 页面刷新
paymentStatusReact useState组件卸载 / 页面刷新
WebSocket 连接Socket.IO 实例组件卸载 / 网络断开

同时,已有的 queryPaymentStatus 函数和后端 POST /api/payment/query-status 端点从未被调用过,是现成但闲置的基础设施。

2. 解决方案概览

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

五层防护机制:

#机制作用触发条件
1sessionStorage持久化支付上下文,跨组件卸载/页面刷新保留initiatePayment 成功后
2轮询回退每 5 秒查后端状态,WebSocket 断连时的安全网stage === 'progress'
3自动恢复组件重新挂载时检测并恢复未完成的支付流程组件 mount + sessionStorage 有数据
4beforeunload阻止用户意外关闭标签页/导航离开stage === 'progress'
5WS 生命周期解耦WebSocket 不再绑定 stage,只要 paymentRequestId 存在就连接!!paymentRequestId

3. 技术实现细节

3.1 sessionStorage 持久化

核心文件: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())
}

为什么选择 sessionStorage 而非 localStorage?

sessionStorage = 标签页作用域

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

3.2 轮询回退机制

Belt and Suspenders(双重保障)模式

WebSocket 是主通道(低延迟、实时推送),轮询是安全网(覆盖 WS 断连、漏消息等边缘情况)。两者共享同一个 applyPaymentStatus 函数,状态转换逻辑完全一致。

参数说明
轮询间隔5 秒平衡后端负载与状态同步延迟
API 端点POST /api/payment/query-status已有基础设施,无需新增后端代码
启动条件stage === 'progress' && !!paymentRequestId仅在支付进行中时轮询
终止条件到达终态 (completed/failed/cancelled/timeout)通过 paymentStatusRef 检查避免重复副作用

防重复执行设计

轮询回调通过 paymentStatusRef(React ref)检查当前状态。如果已经是终态,跳过 API 调用。这避免了以下问题:

3.3 自动恢复逻辑

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

关键设计决策

3.4 beforeunload 防误操作

// 当 stage === 'progress' 时注册
window.addEventListener('beforeunload', (e) => {
  e.preventDefault();
  e.returnValue = '';  // 触发浏览器原生提示
});
// stage 变化或组件卸载时自动移除
注意:现代浏览器(Chrome 51+, Firefox 44+)已忽略自定义的 returnValue 消息文本。显示的是浏览器内置的通用提示(如"离开此网站?你做出的更改可能不会被保存")。但 e.returnValue = '' 赋值仍然是触发提示的必要操作。

3.5 WebSocket 生命周期解耦

修改前修改后
WS enabled stage === 'progress' !!paymentRequestId
影响 stage 被意外改变 → WS 断开 → 丢消息 只要 paymentRequestId 存在 → WS 保持连接
恢复场景 恢复时先设 stage → WS 才连 → 可能错过消息 恢复时先设 paymentRequestId → WS 立即连接 → 不漏消息

4. applyPaymentStatus:统一状态转换

原来 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 内部负责:

  1. 终态时清除 sessionStorage(clearPaymentContext
  2. 更新 paymentStatusstatusMessage
  3. completed → 延迟 1.5s 跳转成功页
  4. cancelled → 延迟 1.5s 重置流程

5. 优点

优点说明
零后端改动完全复用已有的 POST /api/payment/query-status 端点和 queryPaymentStatus 函数
渐进增强sessionStorage 不可用时(SSR、隐私模式配额耗尽)graceful degradation,不影响正常流程
双通道保障WebSocket(实时)+ 轮询(5s)互补,单一通道故障不影响终态同步
自动清理sessionStorage 标签页级别 + 10 分钟过期 + 终态清除 = 三重清理机制,无僵尸数据
单一状态转换源applyPaymentStatus 统一处理所有状态变更,WS 和轮询不会出现行为不一致
用户体验刷新后自动恢复支付监听 + beforeunload 提示 = 店员不会因误操作丢失支付状态

6. 局限性

局限影响风险等级
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 通知。

7. CodePay Void 不可知问题深入分析

这是最重要的局限,值得单独展开:

问题根源

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'

为什么 webhook 也不行?

可能的解决路径(未实施)

方案时机实现复杂度效果
日结对账:Day-End Closeout 时批量调用 CodePay Query Order API 短期可做 中等 T+0 日末发现差异,但不实时
T+1 账单下载:定时任务下载 CodePay 交易报表进行比对 中期 中等 覆盖面广,但有 1 天延迟
Void 事件推送:联系 CodePay 注册 void/refund callback 长期 低(如果 CodePay 支持) 实时同步,但依赖第三方

8. 相关文件

文件角色
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.tsWS 连接(无修改,enabled 参数由调用方控制)
frontend/web_app/src/components/Payment/PaymentProgressModal.tsx进度模态框(无修改,已阻止非终态关闭)
backend/api/payment.js:5616POST /api/payment/query-status 端点(已有,无修改)
specs/payments/015-pos-payment/spec.mdspec 文档(新增韧性章节)
frontend/web_app/src/i18n/locales/{en,zh}/payment.json新增 status.recovered 翻译键

9. 验证清单

#场景预期结果
1正常支付流程(端到端)sessionStorage 写入 → 终态清除,流程不受影响
2Progress 阶段关闭模态框 → 重新打开预约自动恢复到 progress,toast 提示"支付会话已恢复"
3Progress 阶段刷新页面自动恢复,WebSocket + 轮询继续监听
4Progress 阶段尝试关闭标签页浏览器弹出原生"确认离开"提示
5支付完成后检查 sessionStorage对应 key 已被清除
6手动写入 >10min 前的过期数据组件忽略并清除,不触发恢复
7后端不可达时的恢复尝试清除 sessionStorage,不报错,不进入 progress