POS 支付状态机 & 防护机制

PaymentCubit / VoidCubit / Backend 三端状态流转 + 恢复韧性机制 — 更新于 2026-02-19

1. 整体架构概览

POS 支付系统涉及三个独立运行的状态机,通过 WebSocket + REST API 协调:

graph LR
    subgraph Web["Web 前端"]
        W1["RefundModal / CheckoutFlow"]
    end
    subgraph Backend["Node.js 后端"]
        B1["payment_requests 表"]
        B2["transactions 表"]
        B3["refunds 表"]
    end
    subgraph POS["POS 终端 (Flutter)"]
        P1["PaymentCubit"]
        P2["VoidCubit"]
    end

    W1 -- "POST /pos-request" --> B1
    B1 -- "WebSocket pos:payment_request" --> P1
    P1 -- "POST /pos-confirm" --> B2
    B2 -- "WebSocket payment:completed" --> W1

    W1 -- "POST /refunds/card" --> B3
    B3 -- "WebSocket pos:void_request" --> P2
    P2 -- "POST /pos-void-confirm" --> B3
    B3 -- "WebSocket payment:void_completed" --> W1
关键约束:CodePay Intent 是同终端操作 — 支付和 VOID 必须在同一台物理设备上执行。 VOID 通过 orig_trans_no(设备本地交易 ID)定位原始交易。

1.5 端到端全景流程图 (支付 + VOID + 恢复)

下图展示了 POS Intent 支付系统的完整链路,包含正常流、异常流、三条恢复链路:

flowchart TD
    Start(["收银员点击 Checkout"])

    subgraph PAY["正常支付流"]
        P1["POST /pos-request"]
        P2["Backend 创建 payment_request (pending)"]
        P3["WebSocket → POS 设备"]
        P4["POS 自动 accept"]
        P5["客人确认订单 → 选小费"]
        P6["CodePay Intent 扣款"]
        P7{"扣款结果?"}
        P8["POST /pos-confirm (success)"]
        P9["Backend 创建 transaction + invoice"]
        P10["WebSocket → Web: payment:completed"]
        P11(["支付成功 ✅"])
        PF["POST /pos-confirm (failed)"]
        PF2["WebSocket → Web: payment:failed"]
        PF3(["支付失败 ❌"])
    end

    Start --> P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
    P7 -- "成功" --> P8 --> P9 --> P10 --> P11
    P7 -- "失败" --> PF --> PF2 --> PF3

    subgraph SYNC["syncFailed 恢复链路"]
        S1{"3次 confirm 重试"}
        S2["全部失败"]
        S3["SecureStorage.save(JSON)"]
        S4["显示 Success (syncFailed)"]
        S5{"恢复路径?"}
        S6["Web 手动确认到账 (主要)"]
        S7["POS 下一笔请求触发重试 (兜底)"]
        S8["POST /pos-manual-confirm"]
    end

    P7 -- "成功但 confirm 失败" --> S1 --> S2 --> S3 --> S4 --> S5
    S5 -- "收银员操作 Web" --> S6 --> S8 --> P11
    S5 -. "POS 自动" .-> S7

    subgraph REFRESH["Web 刷新恢复链路"]
        R1["页面刷新 / 导航离开"]
        R2["CheckoutFlow mount"]
        R3["GET /pos-request/pending"]
        R4{"有 pending/acknowledged?"}
        R5["恢复进度条 + WebSocket 监听"]
        R6["正常状态"]
        R7{"POS 是否响应?"}
        R8["等待 POS → 正常完成"]
        R9["收银员点击 手动确认到账"]
        R10["输入 tip → POST /pos-manual-confirm"]
        R11["Backend 创建 transaction"]
        R12(["支付成功 ✅"])
    end

    P3 -. "⚡ 刷新" .-> R1 --> R2 --> R3 --> R4
    R4 -- "是" --> R5 --> R7
    R4 -- "否" --> R6
    R7 -- "POS 正常回来" --> R8 --> P10
    R7 -- "POS 无响应" --> R9 --> R10 --> R11 --> R12

    subgraph VOID["VOID 退款流"]
        V1["RefundModal → POST /refunds/card"]
        V2["Backend 检测 source=pos_intent"]
        V3{"设备在线?"}
        V4["创建 refund (pending)"]
        V5["WebSocket → POS: pos:void_request"]
        V6["POS → CodePay VOID Intent"]
        V7{"VOID 结果?"}
        V8["POST /pos-void-confirm (success)"]
        V9["refund → completed"]
        V10(["退款成功 ✅"])
        VF(["退款失败 ❌"])
        VO(["设备离线 → 400 错误"])
    end

    P11 -. "发起退款" .-> V1 --> V2 --> V3
    V3 -- "在线" --> V4 --> V5 --> V6 --> V7
    V3 -- "离线" --> VO
    V7 -- "成功" --> V8 --> V9 --> V10
    V7 -- "失败" --> VF

    subgraph VRECOV["VOID Modal 恢复链路"]
        VR1["关闭 RefundModal"]
        VR2["重新打开 Modal"]
        VR3["GET /refunds/pending"]
        VR4{"有 pending?"}
        VR5["恢复等待状态 + WebSocket"]
        VR6["正常状态"]
    end

    V5 -. "⚡ 关闭" .-> VR1 --> VR2 --> VR3 --> VR4
    VR4 -- "是" --> VR5
    VR4 -- "否" --> VR6

    style PAY fill:#0f1729,stroke:#00d4ff,color:#eee
    style SYNC fill:#1b0f0f,stroke:#f87171,color:#fca5a5
    style REFRESH fill:#0f1b0f,stroke:#4ade80,color:#86efac
    style VOID fill:#1b1b0f,stroke:#fbbf24,color:#fde68a
    style VRECOV fill:#0f1b1b,stroke:#06b6d4,color:#67e8f9
图例蓝框 正常支付流   红框 syncFailed 恢复(Web 手动确认 / POS 自动重试)  绿框 Web 刷新恢复 + 手动确认到账   黄框 VOID 退款流   青框 VOID Modal 恢复   虚线箭头 = 异常中断触发的恢复路径

2. PaymentCubit 状态机 (支付流)

pos_app/lib/features/payment/presentation/bloc/payment_cubit.dart pos_app/lib/features/payment/presentation/bloc/payment_state.dart

2.1 状态流转图

stateDiagram-v2
    [*] --> Idle
    Idle --> RequestReceived: WS payment_request
    RequestReceived --> OrderReview: POST /pos-accept OK
    RequestReceived --> Failure: POST /pos-accept fail
    OrderReview --> TipSelection: 客人确认订单
    TipSelection --> Processing: 客人选择小费
    Processing --> Success: CodePay OK + POST /pos-confirm
    Processing --> Failure: CodePay 拒绝 / Intent 超时

    Success --> Idle: 5s auto idle
    Failure --> Idle: 5s auto idle
    Cancelled --> Idle: 5s auto idle
    Timeout --> Idle: 5s auto idle

    OrderReview --> Cancelled: 收银员/客人取消
    TipSelection --> Cancelled: 收银员取消
    RequestReceived --> Cancelled: WS payment_cancelled
    RequestReceived --> Timeout: WS payment_timeout

    note right of Idle: 收到新请求时,终端状态自动复位
    note right of Processing: CodePay Intent 2分钟超时保护

2.2 状态详解

状态 类别 说明 看门狗超时 触发条件
PaymentIdle 空闲 等待 WebSocket 支付请求 N/A 初始状态 / 流程完成后
PaymentRequestReceived 处理中 收到请求,正在调用 POST /pos-accept 3 分钟 WebSocket pos:payment_request
PaymentOrderReview 交互等待 显示订单详情,等待客人确认 5 分钟 /pos-accept 返回成功
PaymentTipSelection 交互等待 显示小费选项,等待客人选择 5 分钟 客人点击"确认订单"
PaymentProcessing 处理中 CodePay Intent 已发出,等待刷卡结果 3 分钟 客人选择小费金额
PaymentSuccess 终端 支付成功,显示结果 30 秒 CodePay 返回成功 + 服务端确认
PaymentFailure 终端 支付失败,显示错误 30 秒 CodePay 拒绝 / Intent 超时 / Accept 失败
PaymentCancelled 终端 已取消,显示提示 30 秒 收银员从 Web 端取消 / 客人取消
PaymentTimeout 终端 服务端超时(5分钟未完成) 30 秒 WebSocket pos:payment_timeout

3. VoidCubit 状态机 (退款/VOID 流)

pos_app/lib/features/payment/presentation/bloc/void_cubit.dart pos_app/lib/features/payment/presentation/bloc/void_state.dart

3.1 状态流转图

stateDiagram-v2
    [*] --> VoidIdle
    VoidIdle --> VoidRequestReceived: WS void_request
    VoidRequestReceived --> VoidProcessing: 自动执行 VOID
    VoidProcessing --> VoidSuccess: CodePay VOID 成功 + 服务端确认
    VoidProcessing --> VoidFailure: CodePay VOID 失败 / Intent 超时

    VoidSuccess --> VoidIdle: 5秒自动回 idle
    VoidFailure --> VoidIdle: 5秒自动回 idle

    VoidRequestReceived --> VoidIdle: WS void_cancelled

    note right of VoidIdle: 收到新请求时,终端状态自动复位
    note right of VoidProcessing: CodePay Intent 2分钟超时保护

3.2 状态详解

状态 类别 说明 看门狗超时
VoidIdle 空闲 等待 WebSocket VOID 请求 N/A
VoidRequestReceived 处理中 收到 VOID 请求,即将执行 3 分钟
VoidProcessing 处理中 CodePay VOID Intent 执行中 3 分钟
VoidSuccess 终端 VOID 成功,卡已退款 30 秒
VoidFailure 终端 VOID 失败(卡拒绝/超时/设备错误) 30 秒

4. Backend 状态 (数据库记录)

4.1 支付请求生命周期 (payment_requests 表)

stateDiagram-v2
    [*] --> pending: POST /pos-request 创建
    pending --> accepted: POS 调用 /pos-accept
    accepted --> completed: POS 调用 /pos-confirm (success)
    accepted --> completed: Web 手动确认 /pos-manual-confirm
    accepted --> failed: POS 调用 /pos-confirm (failed)
    accepted --> cancelled: 收银员取消 / POS 取消
    pending --> timeout: 5分钟调度器超时
    accepted --> timeout: 5分钟调度器超时
    timeout --> completed: Web 手动确认 /pos-manual-confirm

4.2 退款生命周期 (refunds 表)

stateDiagram-v2
    [*] --> pending: POST /refunds/card 创建
    pending --> completed: POS 调用 /pos-void-confirm (success)
    pending --> failed_device: POS 调用 /pos-void-confirm (failed)
    pending --> failed_timeout: 5分钟调度器超时

    note right of pending: POS 设备离线时直接返回错误,不创建 pending 记录

5. 端到端支付流 (时序图)

sequenceDiagram
    participant Web as Web 前端
    participant API as Backend API
    participant WS as WebSocket
    participant POS as POS 终端
    participant CP as CodePay 硬件

    Web->>API: POST /api/payment/pos-request
    API->>API: 创建 payment_request (pending)
    API->>API: 调度 5 分钟超时
    API->>WS: broadcast pos:payment_request
    WS->>POS: pos:payment_request

    POS->>API: POST /api/payment/pos-accept
    API->>API: payment_request → accepted
    API-->>POS: 200 OK + tipConfig

    Note over POS: 显示订单 → 客人确认 → 选小费

    POS->>CP: Android Intent (initiatePayment)
    Note over CP: 客人刷卡/插卡/NFC
    CP-->>POS: Intent 结果 (success/fail)

    alt 支付成功
        POS->>API: POST /api/payment/pos-confirm (success)
        API->>API: 创建 transaction + invoice
        API->>WS: broadcast payment:completed
        WS->>Web: payment:completed
        Note over Web: 显示"支付成功"
    else 支付成功但 confirm 失败 (syncFailed)
        POS->>API: POST /pos-confirm (attempt 1/3)
        API--xPOS: 网络错误 / 500
        POS->>API: POST /pos-confirm (attempt 2/3, +2s)
        API--xPOS: 网络错误 / 500
        POS->>API: POST /pos-confirm (attempt 3/3, +4s)
        API--xPOS: 网络错误 / 500
        Note over POS: 3次重试全部失败
        POS->>POS: SecureStorage.saveUnsyncedPayment(JSON)
        Note over POS: 显示 Success (syncFailed: true)
    else 支付失败
        POS->>API: POST /api/payment/pos-confirm (failed)
        API->>WS: broadcast payment:failed
        WS->>Web: payment:failed
        Note over Web: 显示"支付失败"
    end

6. 端到端 VOID 流 (时序图)

sequenceDiagram
    participant Web as Web 前端
    participant API as Backend API
    participant WS as WebSocket
    participant POS as POS 终端
    participant CP as CodePay 硬件

    Web->>API: POST /api/payment/refunds/card
    API->>API: 检查 transaction.source === 'pos_intent'
    API->>API: 检查原始 POS 设备是否在线

    alt 设备离线
        API-->>Web: 400 "原支付终端离线"
    else 设备在线
        API->>API: 创建 refund (pending)
        API->>API: 调度 5 分钟超时
        API->>WS: emitToPosDevice(pos:void_request)
        WS->>POS: pos:void_request
        API-->>Web: 200 { waitingForDevice: true }
    end

    POS->>CP: Android Intent (voidTransaction)
    Note over CP: 设备处理 VOID
    CP-->>POS: Intent 结果

    alt VOID 成功
        POS->>API: POST /pos-void-confirm (success)
        Note over POS: 3次重试 + 指数退避
        API->>API: refund → completed, transaction → refunded
        API->>WS: broadcast payment:void_completed
        WS->>Web: void_completed
    else VOID 失败
        POS->>API: POST /pos-void-confirm (failed)
        API->>API: refund → failed
        API->>WS: broadcast payment:void_failed
        WS->>Web: void_failed
    end

    Note over Web: RefundModal 关闭后重新打开时
    Web->>API: GET /refunds/pending/{transactionId}
    API-->>Web: { pendingRefund: {...} } 或 null
    Note over Web: 如有 pending → 自动恢复等待状态 + 重新监听 WebSocket

7. 四层防护机制

系统采用四层纵深防御策略,前三层确保 POS 设备在任何异常情况下都不会永久卡死,第四层防止并发操作导致双重记账:

Layer 1: 终端状态自动复位

问题:上一笔支付完成后,如果 5 秒回 idle 的定时器因为某种原因没有触发,设备将永远卡在 PaymentSuccess,拒绝所有新请求。

方案_onPaymentRequest()_onVoidRequest() 在收到新请求时,如果当前是终端状态(Success / Failure / Cancelled / Timeout),自动复位到 Idle 并处理新请求,而不是丢弃。

// PaymentCubit._onPaymentRequest()
if (state is! PaymentIdle) {
  if (_isTerminalState(state)) {
    // 自动复位 → 继续处理新请求
    emit(const PaymentIdle());
  } else {
    // 真正在处理中 → 拒绝新请求
    return;
  }
}

覆盖场景:定时器被 GC、热重载、系统休眠等干扰后的恢复。

Layer 2: 全局看门狗定时器

问题:交互状态(客人走开不操作)、处理状态(CodePay 挂起)没有超时机制,设备可能长时间不可用。

方案:每次 emit() 非 idle 状态时,自动启动对应的看门狗定时器。超时后强制回 idle。

状态类别 包含的状态 超时时间 场景
终端状态 Success, Failure, Cancelled, Timeout 30 秒 正常 5 秒回 idle 的二次兜底
交互状态 OrderReview, TipSelection 5 分钟 客人离开屏幕不操作
处理状态 RequestReceived, Processing, VoidProcessing 3 分钟 网络卡住 / Intent 挂起 / Accept 超时
// 通过 override emit() 自动管理
@override
void emit(PaymentState state) {
  super.emit(state);
  if (state is PaymentIdle) {
    _stateWatchdog?.cancel();
  } else {
    _startStateWatchdog();  // 根据状态类别选择超时
  }
}

覆盖场景:任何导致状态长期不变的边缘情况(系统 bug、硬件故障、网络中断)。

Layer 3: CodePay Intent 超时保护

问题CodePayIntent.initiatePayment()voidTransaction() 通过 Android Intent 调用 CodePay Register App,如果对方 App 崩溃或挂起,Future 永远不会 resolve。

方案:所有 Intent 调用包裹 Future.timeout(2 minutes)

final result = await _codePayIntent.initiatePayment(...)
  .timeout(
    const Duration(minutes: 2),
    onTimeout: () => CodePayResult.failed(
      errorCode: 'INTENT_TIMEOUT',
      errorMessage: 'Payment terminal did not respond within 2 minutes.',
    ),
  );

覆盖场景:CodePay Register App 崩溃、ANR、系统级对话框阻塞。

Layer 4: 数据库级 FOR UPDATE 并发锁

问题:POS 自动重试 /pos-confirm 和 Web 手动确认 /pos-manual-confirm 可能同时到达 Backend,两者都读到 status = 'acknowledged',各自创建一条 transaction → 双重记账

方案:所有修改 payment_requests 的关键写操作,在事务内使用 SELECT ... FOR UPDATE 获取行级排他锁。

// pos-manual-confirm(手动确认)
SELECT * FROM payment_requests
  WHERE id = $1 AND status IN ('acknowledged', 'timeout')
  FOR UPDATE;   -- ← 后到的请求在此阻塞

// pos-payment-service._handleSuccessResult(POS 自动确认)
SELECT * FROM payment_requests
  WHERE id = $1 AND status = $2
  FOR UPDATE;   -- ← 同一行只有一个事务能通过

覆盖场景:POS 自动重试与 Web 手动确认的并发竞态、多个 POS 设备竞争同一请求。详见第 14 节"并发锁机制设计决策"。

8. Backend 超时保护

除了 POS 端的防护,后端也有独立的超时机制:

8.1 支付请求超时 (5 分钟)

backend/services/pos-payment-timeout-scheduler.js

触发条件:创建 payment_request 后调度 5 分钟延迟任务。

超时动作

  1. UPDATE payment_requests SET status = 'timeout' WHERE id = $1 AND status IN ('pending', 'accepted')
  2. WebSocket 广播 pos:payment_timeout
  3. Web 端显示"支付请求超时"

取消条件:POS 调用 /pos-confirm 成功后取消超时调度。

8.2 VOID 请求超时 (5 分钟)

backend/api/payment.js (refunds/card 端点)

触发条件:创建 refund (pending) 后调度 5 分钟延迟任务。

超时动作

  1. UPDATE refunds SET refund_status = 'failed' WHERE id = $1 AND refund_status = 'pending'
  2. WebSocket 广播 payment:void_failed
  3. Web 端显示"VOID 请求超时"

取消条件:POS 调用 /pos-void-confirm 成功后取消超时调度。

9. 异常场景全景 & 对应防护

异常场景 影响 防护层 恢复机制
POS 卡在 PaymentSuccess,新请求被拒绝 严重 设备不可用 Layer 1 + 2 新请求到来时自动复位;30 秒看门狗兜底
客人离开屏幕不操作(OrderReview / TipSelection) 中等 设备被占用 Layer 2 5 分钟看门狗强制回 idle
CodePay Intent 永不返回(App 崩溃 / ANR) 严重 Future 永不 resolve Layer 3 + 2 2 分钟 Future.timeout → Failure;3 分钟看门狗兜底
POS 设备离线(发起 VOID 时) 中等 VOID 无法执行 Backend 创建 refund 前检查设备在线状态,离线直接返回 400
POS → Backend confirm 网络失败 严重 钱已扣但后端不知 POS 重试 + 持久化 3 次指数退避重试;全部失败 → SecureStorage 持久化 + 显示 Success (syncFailed: true)
POS confirm 全部重试失败后收到新请求 严重 可能双重扣款 syncFailed 持久化 读取 SecureStorage → 重试上一笔 confirm → 成功则清除并接受新请求;失败则拒绝新请求
syncFailed — 收银员从 Web 手动确认到账 已解决 主要恢复路径 Web 手动确认 + Layer 4 恢复进度条后点击"手动确认到账" → 输入 tip → POST /pos-manual-confirm → FOR UPDATE 锁 → 创建 transaction → SYNC_CLEARED 通知 POS 清除 SecureStorage
POS 自动重试 与 Web 手动确认同时到达 严重 双重记账 Layer 4(FOR UPDATE) 两个请求串行化:先到者获锁并创建 transaction;后到者阻塞后读到 status 已变 → 404 拒绝
Web 页面刷新(POS 支付进行中) 中等 支付进度丢失 Web 刷新恢复 CheckoutFlow mount 时查询 GET /pos-request/pending → 恢复进度条 + WebSocket 监听
RefundModal 关闭后重开(VOID 进行中) 中等 VOID 状态丢失 VOID Modal 恢复 RefundModal 打开时查询 GET /refunds/pending → 恢复等待状态 + WebSocket 监听
VOID 在 CodePay 成功但 confirm 失败 严重 卡已退但 refund 为 pending POS 重试 + Backend 超时 3 次重试;失败后仍显示 VoidSuccess;Backend 5 分钟后标记 failed(需人工对账)
Backend 重启导致 WebSocket 断开 中等 POS 收不到事件 WebSocket 自动重连 Flutter WebSocket Client 内置自动重连(指数退避);重连后自动加入 room
CodePay VOID 返回 M011(非当天交易) 中等 VOID 不可执行 错误透传 POS 报告 failed → Web 显示错误 → 需使用 Cloud Mode Refund(trans_type=3)
CodePay VOID 返回 E04112(商户订单号重复) 中等 VOID 被拒 唯一 ID 生成 每次 VOID 生成时间戳唯一 merchant_order_noV_{origOrder}_{timestamp}

10. 超时时间汇总

gantt
    title 超时防护时间线
    dateFormat X
    axisFormat %Ls

    section POS 端
    _scheduleReturnToIdle (终端状态)  :a1, 0, 5
    看门狗-终端状态 (30s)            :a2, 0, 30
    看门狗-交互状态 (5min)           :a3, 0, 300
    看门狗-处理状态 (3min)           :a4, 0, 180
    Intent timeout (2min)            :a5, 0, 120

    section Backend
    POS payment timeout (5min)       :b1, 0, 300
    VOID request timeout (5min)      :b2, 0, 300

    section POS confirm 重试
    第1次 (立即)                     :c1, 0, 1
    第2次 (2s 后)                    :c2, 2, 3
    第3次 (4s 后)                    :c3, 6, 7
防护机制 位置 超时时间 触发后果
_scheduleReturnToIdle POS PaymentCubit / VoidCubit 5 秒 终端状态自动回 Idle
State Watchdog (终端) POS PaymentCubit / VoidCubit 30 秒 强制回 Idle(_scheduleReturnToIdle 的兜底)
State Watchdog (交互) POS PaymentCubit 5 分钟 客人走开 → 强制回 Idle
State Watchdog (处理) POS PaymentCubit / VoidCubit 3 分钟 处理卡住 → 强制回 Idle
Future.timeout POS CodePayIntent 调用 2 分钟 Intent 挂起 → 返回 INTENT_TIMEOUT 失败
Backend 支付超时调度 Backend pos-payment-timeout-scheduler 5 分钟 payment_request → timeout + 广播
Backend VOID 超时调度 Backend refunds/card 端点 5 分钟 refund → failed + 广播
Server confirm 重试 POS PaymentCubit / VoidCubit 2s + 4s(指数退避) 最多 3 次重试,全部失败仍显示 Success

11. 恢复与韧性机制 (Recovery & Resilience)

系统在正向流(支付/VOID)的四层防护之外,还提供四条恢复与安全链路,处理"中途断开"和"并发竞争"场景:

完整恢复架构

flowchart TB
    subgraph Disruption["中断场景"]
        D1["Web 页面刷新"]
        D2["POS confirm 失败 (syncFailed)"]
        D3["RefundModal 关闭后重开"]
    end

    subgraph Recovery["恢复机制"]
        R1["GET /pos-request/pending
查询 pending/acknowledged 请求"] R2a["Web 手动确认到账 (主要)
POST /pos-manual-confirm"] R2b["POS SecureStorage 自动重试 (兜底)
下一笔请求时重试 confirm"] R3["GET /refunds/pending
查询 pending/processing 退款"] end subgraph Result["恢复结果"] O1a["恢复进度条 → 等待 POS 响应"] O1b["手动确认到账 → 创建 transaction"] O2["POS 自动重试 / 拒绝新请求"] O3["自动恢复 VOID 等待状态"] end D1 --> R1 --> O1a O1a -. "POS 无响应" .-> O1b D2 --> R2a --> O1b D2 --> R2b --> O2 D3 --> R3 --> O3 style Disruption fill:#2d1b1b,stroke:#f87171,color:#fca5a5 style Recovery fill:#1b2d1b,stroke:#4ade80,color:#86efac style Result fill:#1b1b2d,stroke:#60a5fa,color:#93c5fd

11.1 Web 页面刷新恢复

frontend/web_app/src/components/Payment/NewCheckoutFlow.tsx backend/api/payment.js — GET /api/payment/pos-request/pending/:appointmentId
场景:收银员发起 POS 支付后刷新浏览器页面,React 状态全部丢失。
sequenceDiagram
    participant Web as Web 前端
    participant API as Backend API
    participant WS as WebSocket
    participant POS as POS 终端

    Note over Web: 收银员发起支付
    Web->>API: POST /api/payment/pos-request
    API->>WS: broadcast pos:payment_request
    WS->>POS: pos:payment_request
    POS->>API: POST /pos-accept

    Note over Web: ⚡ 页面刷新 — React 状态全部丢失

    Web->>Web: CheckoutFlow 组件 mount
    Web->>API: GET /pos-request/pending/{appointmentId}
    API-->>Web: { pendingRequest: { id, status, deviceId, orderNumber } }

    alt 有 pending/accepted 请求
        Note over Web: 恢复 posRequestId + showPosProgress + isPosRecovered=true
        Web->>WS: 开始监听 payment:completed / payment:failed
        Note over Web: 显示"已恢复 POS 支付 — 正在等待终端响应"

        alt POS 正常响应
            POS->>API: POST /pos-confirm (success)
            API->>WS: broadcast payment:completed
            WS->>Web: payment:completed
            Note over Web: 正常显示"支付成功"
        else POS 无响应(syncFailed / 设备离线)
            Note over Web: 收银员点击"手动确认到账"
            Note over Web: 输入小费金额
            Web->>API: POST /pos-manual-confirm { requestId, tipAmount }
            API-->>Web: { success, totalPaid }
            Note over Web: 补建 transaction → 显示"支付成功" ✅
        end
    else 无 pending 请求
        Note over Web: 正常状态,不做恢复
    end
检查项
触发时机CheckoutFlow useEffect mount 时
查询端点GET /api/payment/pos-request/pending/:appointmentId
查询条件status IN ('pending', 'accepted') AND created_at > NOW() - 30min
恢复内容posRequestId, showPosProgress, posStatus
用户反馈Toast: "已恢复待处理的 POS 支付请求"

11.2 POS syncFailed 持久化恢复

pos_app/lib/core/storage/secure_storage.dart pos_app/lib/features/payment/presentation/bloc/payment_cubit.dart
场景:POS 成功扣款但 3 次 server confirm 全部失败。之前这笔交易信息在 5 秒后随状态回 idle 丢失,导致再次发起新支付时可能双重扣款。
sequenceDiagram
    participant POS as POS 终端
    participant SS as SecureStorage
    participant API as Backend API
    participant CP as CodePay 硬件

    Note over POS, CP: 支付场景 — confirm 失败

    POS->>CP: CodePay Intent (扣款)
    CP-->>POS: 扣款成功 ✅

    POS->>API: POST /pos-confirm (attempt 1) ❌
    POS->>API: POST /pos-confirm (attempt 2, +2s) ❌
    POS->>API: POST /pos-confirm (attempt 3, +4s) ❌

    POS->>SS: saveUnsyncedPayment(JSON)
    Note over SS: 加密持久化到 Android Keystore

    Note over POS: 显示 PaymentSuccess (syncFailed: true)
    Note over POS: 5s → 回到 Idle

    Note over POS: ⏰ 新支付请求到达

    POS->>SS: 读取 unsyncedPayment
    SS-->>POS: JSON payload (上一笔未同步)

    POS->>API: POST /pos-confirm (重试上一笔)
    alt 重试成功
        API-->>POS: 200 OK
        POS->>SS: clearUnsyncedPayment()
        Note over POS: 清除后正常接受新请求
    else 重试仍然失败
        Note over POS: 拒绝新请求,防止双重扣款
        Note over POS: Log CRITICAL 等待人工介入
    end
检查项
触发时机_onPaymentRequest() — 收到新支付请求时
存储位置SecureStorageService(Android Keystore / iOS Keychain 加密)
存储 Keyunsynced_payment
存储内容{ requestId, codepayTransactionId, tipAmount, totalPaid, intentResponse, timestamp }
防护效果有未同步记录时拒绝新请求 → 防止双重扣款
清除条件重试 confirm 成功后自动清除

11.3 VOID Modal 恢复

frontend/web_app/src/components/Payment/RefundModal.tsx backend/api/payment.js — GET /api/payment/refunds/pending/:transactionId
场景:收银员发起 POS VOID 后关闭 RefundModal,再次打开时等待状态丢失,看不到 VOID 完成/失败结果。
sequenceDiagram
    participant Web as RefundModal
    participant API as Backend API
    participant WS as WebSocket
    participant POS as POS 终端

    Note over Web: 第一次打开 — 发起 VOID
    Web->>API: POST /refunds/card
    API->>POS: WebSocket pos:void_request
    API-->>Web: { waitingForDevice: true, refundId }
    Note over Web: 显示"等待 POS 终端..."

    Note over Web: ⚡ 用户关闭 Modal — React 状态丢失

    Note over Web: 再次打开 Modal
    Web->>API: GET /refunds/pending/{transactionId}
    API-->>Web: { pendingRefund: { refundId, status: 'pending' } }

    alt 有 pending 退款
        Note over Web: 自动恢复 waitingForDevice 状态
        Web->>WS: 开始监听 void_completed / void_failed

        POS->>API: POST /pos-void-confirm (success)
        API->>WS: broadcast payment:void_completed
        WS->>Web: void_completed
        Note over Web: 正常显示"退款成功" ✅
    else 无 pending 退款
        Note over Web: 正常状态,允许发起新退款
    end
检查项
触发时机RefundModal useEffect — isPosIntent 交易 + modal 打开时
查询端点GET /api/payment/refunds/pending/:transactionId
查询条件refund_status IN ('pending', 'processing')
恢复内容voidRefundId, voidRefundNumber, waitingForDevice
自动效果恢复状态后触发 WebSocket 监听的 useEffect 重新连接

11.4 Web 手动确认到账(syncFailed 主要恢复路径)

frontend/web_app/src/components/Payment/PosPaymentProgress.tsx frontend/web_app/src/components/Payment/NewCheckoutFlow.tsx backend/api/payment.js — POST /api/payment/pos-manual-confirm
场景:POS 终端已成功扣款(CodePay Intent 返回 success),但 3 次 POST /pos-confirm 全部失败(syncFailed)。 卡已扣钱,但 Backend 没有 transaction 记录。收银员通过 Web 端手动确认这笔支付。
sequenceDiagram
    participant Web as Web 前端
    participant API as Backend API
    participant DB as PostgreSQL
    participant WS as WebSocket
    participant POS as POS 终端

    Note over Web: 恢复的 POS 进度条(isPosRecovered=true)
    Note over Web: POS 终端无响应 / 客人已离开

    Web->>Web: 收银员点击"手动确认到账"
    Note over Web: 展开面板 — 输入小费金额(tip 上限 = 税前金额)

    Web->>API: POST /pos-manual-confirm
{ requestId, tipAmount } API->>DB: BEGIN TRANSACTION API->>DB: SELECT ... FOR UPDATE
(status IN acknowledged, timeout) Note over DB: 行级排他锁 — 阻塞并发 pos-confirm DB-->>API: payment_request 数据 Note over API: 验证 tipAmount 小于等于 baseTotal Note over API, DB: 1. INSERT transactions(无 CodePay 详情,notes: Manual confirm) Note over API, DB: 2. UPDATE payment_requests → completed Note over API, DB: 3. UPDATE invoices → paid Note over API, DB: 4. syncAppointmentPaymentStatus API->>DB: COMMIT API->>WS: broadcastToPaymentRequest
payment:completed { manual_confirm: true } API->>WS: broadcastToStore
payment:completed { manual_confirm: true } WS->>Web: payment:completed Note over Web: 显示"支付已手动确认成功" API->>WS: emitToPosDevice(SYNC_CLEARED) WS->>POS: pos:sync_cleared { request_id, order_id } Note over POS: clearUnsyncedPayment() — 清除 SecureStorage
检查项
端点POST /api/payment/pos-manual-confirm
认证JWT(员工登录),非 device-auth
权限要求day_end_closeout:manage / day_end_closeout:unlock / settings:manage(任一)
前置条件payment_request.status IN ('acknowledged', 'timeout') — 超时请求也可手动确认(卡可能已扣款)
输入参数requestId(必填)+ tipAmount(≥0,必填,上限 = subtotal - discountAmount + taxAmount)
并发保护SELECT ... FOR UPDATE 行级排他锁 — POS 自动重试与 Web 手动确认串行化,防止双重 transaction(详见第 14 节)
Tip 上限tipAmount > baseTotal 时返回 400 — 防止输入异常金额
幂等保障FOR UPDATE 锁 + status 检查:若 POS 已先成功(status 已变),后到的手动确认读到 0 行 → 404
Transaction 特征card_last_four、无 codepay_trans_no,notes 标记 "Manual confirm — no CodePay response"
WebSocket 广播双通道:broadcastToPaymentRequest + broadcastToStorepayment:completed,附 manual_confirm: true
POS 通知emitToPosDevice(SYNC_CLEARED, { request_id, order_id }) — 通知 POS 清除 SecureStorage 中的未同步记录(best-effort)
UI 入口PosPaymentProgress 组件 — 仅 isRecovered=true 时显示按钮

四条恢复链路对比

恢复机制 中断原因 数据源 风险等级 恢复方式
Web 刷新恢复 浏览器刷新/导航离开 Backend DB (payment_requests) UX 中断 查询 pending 请求 → 恢复进度条
syncFailed 持久化 POS → Backend 网络断开 POS 加密本地存储 双重扣款 拒绝新请求 + 重试 confirm
VOID Modal 恢复 关闭/重开 RefundModal Backend DB (refunds) UX 中断 查询 pending 退款 → 恢复监听
Web 手动确认到账 POS confirm 全部失败(syncFailed)/ 请求超时 Backend DB (payment_requests) + 收银员输入 tip 卡已扣款无记录 收银员手动输入 tip → POST /pos-manual-confirm → FOR UPDATE 锁 → 补建 transaction → SYNC_CLEARED 通知 POS

12. 已知限制 & 未来改进

12.1 ✅ syncFailed 恢复体系(已修复 2026-02-18)

当支付/VOID 在 CodePay 成功但 3 次 server confirm 全部失败时,POS 显示成功后 5 秒回 idle,未同步的交易信息随之丢失。

已实现双重恢复 + 并发安全

  1. 主要路径 — Web 手动确认到账(第 11.4 节):收银员刷新 Web 页面 → 恢复 POS 进度条 → 点击"手动确认到账" → 输入小费(上限 = 税前金额)→ POST /pos-manual-confirm(FOR UPDATE 锁)补建 transaction → 双通道广播 + SYNC_CLEARED 通知 POS 清除 SecureStorage。无需等待 POS 终端响应。支持 acknowledgedtimeout 两种状态的请求。
  2. 兜底路径 — POS 自动重试(第 11.2 节):syncFailed 时将交易信息持久化到 SecureStorage(Android Keystore 加密),收到新支付请求时自动检测并重试。重试失败则拒绝新请求,防止双重扣款。
  3. 并发保护 — 数据库 FOR UPDATE 锁(第 14 节):Web 手动确认与 POS 自动重试可能同时到达,通过 PostgreSQL 行级排他锁确保只有一个请求能成功创建 transaction,彻底杜绝双重记账。

12.2 VOID 仅限当天(Same-Day Settlement)

CodePay Intent VOID(trans_type: 2)只能在当天结算前执行。过了结算时间的交易会返回 M011 错误。

改进方向:检测 M011 后自动降级为 Cloud Mode Refund(trans_type: 3),或在 Web 端提示用户选择其他退款方式。

12.3 对账能力

当出现 POS-Backend 数据不一致时,可通过以下方式对账:

13. 关键代码文件索引

组件 文件路径 职责
PaymentCubit pos_app/lib/features/payment/presentation/bloc/payment_cubit.dart POS 支付状态机 + 看门狗 + 自动复位
VoidCubit pos_app/lib/features/payment/presentation/bloc/void_cubit.dart POS VOID 状态机 + 看门狗 + 自动复位
CodePayIntent pos_app/lib/core/intent/codepay_intent.dart Android Intent 桥接(MethodChannel → CodePay Register)
VoidRequest pos_app/lib/core/models/void_request.dart VOID 请求数据模型
PaymentRepository pos_app/lib/features/payment/data/payment_repository.dart POS → Backend HTTP 调用(accept/confirm/void-confirm)
Payment API backend/api/payment.js Backend 支付路由(pos-request/pos-accept/pos-confirm/pos-manual-confirm/pos-void-confirm/refunds/card)
Timeout Scheduler backend/services/pos-payment-timeout-scheduler.js Backend 5 分钟支付/VOID 超时调度
Notification Service backend/services/paymentNotificationService.js WebSocket 广播(broadcastToStore / emitToPosDevice)
Device Auth backend/middleware/device-auth.js POS 设备令牌认证(X-Device-Token)
Public API Patterns backend/config/api-permission-map.js pos-accept/pos-confirm/pos-void-confirm 豁免 JWT 认证
SecureStorage pos_app/lib/core/storage/secure_storage.dart 加密持久化 syncFailed 未同步支付记录
NewCheckoutFlow frontend/web_app/src/components/Payment/NewCheckoutFlow.tsx Web 支付页面 — 包含 POS 刷新恢复 + 手动确认调度
PosPaymentProgress frontend/web_app/src/components/Payment/PosPaymentProgress.tsx POS 支付进度条组件 — 包含手动确认到账 UI(tip 输入 + confirm 按钮)
RefundModal frontend/web_app/src/components/Payment/RefundModal.tsx Web 退款弹窗 — 包含 VOID 恢复逻辑

14. 并发锁机制设计决策 (FOR UPDATE)

本节记录 POS 支付系统中引入 PostgreSQL FOR UPDATE 行级锁的决策过程、技术原理和替代方案对比。

14.1 为什么需要这个机制

问题场景:POS 终端已成功扣款(CodePay Intent 返回 success),但 3 次 POST /pos-confirm 全部失败。此时:

两条路径可能同时到达 Backend:POS 重试的 pos-confirm 和 Web 的 pos-manual-confirm。两者都会读取同一条 payment_request 记录(status = 'acknowledged'),各自创建一条 transaction

后果:同一笔支付对应两条 transaction,导致双重记账、invoice 重复标记 paid、appointment 支付状态异常。

14.2 PostgreSQL FOR UPDATE 工作原理

在 PostgreSQL 默认的 READ COMMITTED 隔离级别下:

sequenceDiagram
    participant T1 as 事务 A (先到)
    participant DB as PostgreSQL Row
    participant T2 as 事务 B (后到)

    T1->>DB: SELECT ... FOR UPDATE (status=acknowledged)
    Note over DB: 行被 T1 锁定
    DB-->>T1: 返回 payment_request 数据

    T2->>DB: SELECT ... FOR UPDATE (status=acknowledged)
    Note over DB: T2 在此阻塞等待 T1 释放锁

    Note over T1: INSERT transaction
    Note over T1: UPDATE status → completed
    T1->>DB: COMMIT
    Note over DB: 锁释放 — T2 被唤醒

    Note over T2: 重新读取最新数据
    DB-->>T2: status = completed (非 acknowledged)
    Note over T2: WHERE 条件不匹配 → 0 行返回 → 404

关键机制

  1. 行级排他锁FOR UPDATE 在 SELECT 时获取行级写锁,其他事务的 SELECT FOR UPDATEUPDATEDELETE 都会阻塞
  2. READ COMMITTED 重读:被阻塞的事务在锁释放后,会重新读取最新的已提交数据(不是读到旧版本),因此能看到 T1 已将 status 改为 'completed'
  3. WHERE 过滤:重读后 status 不再匹配 WHERE 条件 → 返回 0 行 → 应用层判断为 404 → 不会创建第二条 transaction

这与普通 SELECT(无 FOR UPDATE)不同——普通 SELECT 在 READ COMMITTED 下不阻塞,两个事务可同时读到 status = 'acknowledged'

14.3 替代方案对比

方案 原理 优点 缺点 是否采用
A. 纯应用层控制
Web 确认后通知 POS 停止重试
手动确认成功后发送 WebSocket SYNC_CLEARED,POS 清除 SecureStorage 并停止重试 简单直观;不依赖数据库特性 无法保证:WebSocket 消息可丢失(网络断开/POS 离线);存在发送前的时间窗口 辅助 作为 best-effort 补充,非安全保障
B. 乐观并发控制
UPDATE WHERE status = 'acknowledged'
不在 SELECT 时加锁,而是在最终 UPDATE 时检查:UPDATE payment_requests SET status = 'completed' WHERE id = $1 AND status = 'acknowledged' RETURNING * 无阻塞,高并发下性能更好 浪费:已经执行了 INSERT transaction,发现 UPDATE 返回 0 行后需要回滚整个事务 不采用 先做了无用 INSERT 再回滚,不够干净
C. 悲观锁 FOR UPDATE
SELECT 时获取排他锁
事务开始时就锁定目标行,后到者阻塞在 SELECT 阶段,不会进入 INSERT 最干净:失败者在最早阶段被拦截,不做任何无用工作 理论上阻塞时间增加(锁持续到 COMMIT);极端高并发下可能成为瓶颈 主方案 支付场景并发极低(同一请求最多 2 个竞争者),不存在瓶颈问题
D. UNIQUE 约束
在 transactions 表加唯一约束
transactions (order_id)(payment_request_id) 加 UNIQUE 约束,重复 INSERT 时违反约束报错 数据库层面绝对保障;无需修改查询逻辑 需要 DDL 变更(加 migration);错误处理不如 FOR UPDATE 优雅(需捕获 constraint violation);现有数据可能冲突 未来 可作为额外安全网,目前 FOR UPDATE 已足够

14.4 最终设计:双层保护

flowchart LR
    subgraph DB["数据库层(保证)"]
        FORUPDATE["SELECT ... FOR UPDATE
行级排他锁"] CHECK["status 检查
不匹配 → 404"] FORUPDATE --> CHECK end subgraph APP["应用层(优化)"] CLEARED["SYNC_CLEARED
WebSocket 通知 POS"] CLEAR["POS clearUnsyncedPayment
停止重试"] CLEARED --> CLEAR end DB -. "COMMIT 后触发" .-> APP style DB fill:#1b2d1b,stroke:#4ade80,color:#86efac style APP fill:#1b1b2d,stroke:#60a5fa,color:#93c5fd

为什么选择 C + A 组合

  1. 数据库层 (C) 提供原子性保证:无论网络状况如何,FOR UPDATE 确保同一条 payment_request 只能被一个事务成功处理。这是不可绕过的硬保障。
  2. 应用层 (A) 减少不必要的竞争:SYNC_CLEARED 通知 POS 清除 SecureStorage,让 POS 不再发起重试。即使通知丢失,数据库锁仍然兜底。这是best-effort 的性能优化,不是安全保障。
  3. 支付场景的并发特征:同一笔 payment_request 最多有 2 个竞争者(POS 重试 + Web 手动确认),FOR UPDATE 的短暂阻塞(几十毫秒级)完全可接受,不存在高并发瓶颈。

14.5 受影响的代码位置

端点 / 方法 文件 锁定条件 作用
POST /pos-manual-confirm backend/api/payment.js status IN ('acknowledged', 'timeout') FOR UPDATE Web 手动确认 — 防止与 POS 自动重试竞争
_handleSuccessResult() backend/services/pos-payment-service.js status = 'acknowledged' FOR UPDATE POS 自动确认 — 防止与 Web 手动确认竞争