POS Tip Adjust 失败兜底机制

三种故障场景的超时、对账与手动回退方案 — 更新于 2026-03-09

1. 背景

POS Tip Adjust 是 CodePay Intent Mode 的后付小费流程:Web 前端发起请求 → 后端通过 WebSocket 推送到 POS 设备 → POS 设备调用 CodePay SDK 修改授权金额 → 回调后端确认。

但 POS 设备是一个不可靠的外部系统,存在三种故障场景:

场景 触发条件 tip_adjust_status 处理方式
POS 报失败 POS 主动回报 success=false failed 前端显示 Retry POS / Enter Manually
POS 超时无回调 60s 内无任何 POS 响应 timeout 先查 CodePay API 自动对账,失败则显示手动录入
POS 成功但回调丢失 60s 超时后 CodePay 查到成功 completed (自动) 自动对账,无需人工干预

2. 正常流程(无故障)

sequenceDiagram
    participant Web as Web 前端
    participant BE as 后端
    participant POS as POS 设备
    participant CP as CodePay

    Web->>BE: requestTipAdjust(txId, $10)
    BE->>BE: status = 'pending'
    BE->>BE: scheduleTimeout(60s)
    BE->>POS: WebSocket: TIP_ADJUST_REQUEST
    POS->>CP: Adjust tip via SDK
    CP-->>POS: Success
    POS->>BE: POST /pos-tip-adjust-confirm (success=true)
    BE->>BE: cancelTimeout()
    BE->>BE: confirmTipAdjust() / status = 'completed'
    BE-->>Web: WebSocket: TIP_ADJUST_COMPLETED
    Web->>Web: 刷新交易列表

3. 故障场景详解

场景一:POS 报失败 failed

POS 终端主动回报操作失败(刷卡被拒、通信错误等)。这是最简单的场景 — POS 给了明确的失败信号。

sequenceDiagram
    participant Web as Web 前端
    participant BE as 后端
    participant POS as POS 设备

    BE->>POS: TIP_ADJUST_REQUEST
    POS-->>BE: confirm(success=false, error="Card declined")
    BE->>BE: cancelTimeout()
    BE->>BE: status = 'failed'
    BE-->>Web: TIP_ADJUST_FAILED
    Web->>Web: 显示 "POS Failed" + 两个按钮

用户看到:

场景二:POS 超时无回调 timeout

POS 设备没有任何响应(网络断开、设备死机、App crash 等)。后端 60 秒超时触发后,先尝试 CodePay Query Order API 自动对账。

sequenceDiagram
    participant Web as Web 前端
    participant BE as 后端
    participant POS as POS 设备
    participant CP as CodePay API

    BE->>POS: TIP_ADJUST_REQUEST
    Note over POS: 无响应...
    Note over BE: 60 秒超时
    BE->>BE: handleTipAdjustTimeout()
    BE->>CP: POST /api/entry/orderquery
    CP-->>BE: found: false
    BE->>BE: status = 'timeout'
    BE-->>Web: TIP_ADJUST_TIMEOUT
    Web->>Web: 显示 "POS Timed Out" + 两个按钮

用户看到:

场景三:POS 成功但回调丢失 自动对账

POS 设备实际完成了小费调整,但回调消息因网络抖动丢失。这是最重要的场景 — 自动对账,用户完全无感知

sequenceDiagram
    participant Web as Web 前端
    participant BE as 后端
    participant POS as POS 设备
    participant CP as CodePay API

    BE->>POS: TIP_ADJUST_REQUEST
    POS->>CP: Adjust tip ($10)
    CP-->>POS: Success
    Note over POS,BE: 回调丢失 X
    Note over BE: 60 秒超时
    BE->>BE: handleTipAdjustTimeout()
    BE->>CP: POST /api/entry/orderquery
    CP-->>BE: found: true, tipAmount: $10
    BE->>BE: confirmTipAdjust(自动对账)
    BE->>BE: status = 'completed'
    BE-->>Web: TIP_ADJUST_COMPLETED
    Web->>Web: 刷新交易列表(小费已自动更新)
用户完全无感知 — 60 秒后系统自动发现 CodePay 已处理成功,直接补录小费金额、更新发票、同步分配。

4. 系统架构

4.1 tip_adjust_status 状态机

stateDiagram-v2
    [*] --> pending : requestTipAdjust()
    pending --> completed : POS confirm(success=true)
    pending --> failed : POS confirm(success=false)
    pending --> timeout : 60s 超时 + CodePay 无结果
    pending --> completed : 60s 超时 + CodePay 有结果(自动对账)
    failed --> pending : Retry POS
    timeout --> pending : Retry POS
    failed --> completed : Enter Manually (pos_fallback)
    timeout --> completed : Enter Manually (pos_fallback)

4.2 超时调度机制

复用现有基础设施pos-payment-timeout-scheduler.js 是 067-pos-payment-intent 中为支付请求超时建立的调度器,基于 setTimeout + Map 实现。Tip Adjust 直接复用,使用 tip_adjust:{transactionId} 作为 key。

时机 操作 位置
发送到 POS 后 scheduleTimeout(60s, handleTipAdjustTimeout) requestTipAdjust() 末尾
POS 回调到达 cancelTimeout() pos-device-routes.js + confirmTipAdjust() 开头(双重取消)
60s 超时触发 handleTipAdjustTimeout() 查 CodePay → 自动对账 或 设 timeout

4.3 手动录入绕过机制

POS 卡交易的 tip_entry_methodcard_post_payment,正常情况下不允许通过手动小费工作流编辑(EDITABLE_TIP_METHODS guard)。

新增 pos_fallback 方法绕过这个 guard:

// tip-entry-service.js
const EDITABLE_TIP_METHODS = new Set([null, 'cash_post_payment', 'manual_adjustment', 'pos_fallback']);

// saveTipEntry 中的 guard
const isPosFallback = normalizedInput.tipEntryMethod === 'pos_fallback';
if (!isPosFallback && !EDITABLE_TIP_METHODS.has(transaction.current_tip_entry_method)) {
  throw errors.badRequest('Card-recorded tips cannot be edited from the cash tip workflow');
}
pos_fallback 会被记录在 transaction_tip_historytip_entry_method 列中,便于审计追踪哪些小费是通过 POS 故障回退录入的。

5. 并发保护机制

机制 作用 位置
双重 cancelTimeout 路由层 + 服务层都取消超时,防止 POS 回调后超时仍触发 pos-device-routes.js + confirmTipAdjust()
status = 'pending' 前置检查 超时 handler 只处理仍在 pending 的交易,已 completed/failed 的跳过 handleTipAdjustTimeout()
乐观锁 UPDATE WHERE tip_adjust_status = $3 防止并发写入 handleTipAdjustTimeout()
FOR UPDATE OF t 行锁 confirmTipAdjust() 用行锁防止并发对账 confirmTipAdjust() 内的 SELECT
tipAdjustableStatuses 全额退款的交易不显示 fallback UI TransactionTipEditor.tsx
isSaveLoading + manualMode 前端防止双击提交和按钮状态混乱 TransactionTipEditor.tsx

极端竞态:超时 handler 与 POS 回调同时到达

如果 handleTipAdjustTimeout 正在查询 CodePay 时,POS 回调同时到达:

  1. POS 回调的 confirmTipAdjust 获取行锁,status → completed
  2. 超时 handler 的 confirmTipAdjust 发现 status 已是 completed,抛出 "Tip has already been adjusted"
  3. 超时 handler catch 住异常,fall through(但交易已正确对账)

结果:安全。先到的写入生效,后到的被幂等检查拒绝。

6. 涉及文件

后端

前端

测试

7. 配置项

环境变量 默认值 说明
CODEPAY_ACCESS_TOKEN 空(跳过查询) CodePay API 认证 Token,未配置时超时直接降级为手动录入
CODEPAY_QUERY_URL https://api.codepay.us/api/entry/orderquery CodePay Query Order API 地址
常量 位置
PosConfig.TIP_ADJUST_TIMEOUT_SECONDS 60 backend/constants/pos.js
注意:如果 CODEPAY_ACCESS_TOKEN 未配置,场景三(自动对账)不会工作 — 所有超时都会直接降级为手动录入。生产环境必须配置此 Token。

8. 相关文档