PaymentCubit / VoidCubit / Backend 三端状态流转 + 恢复韧性机制 — 更新于 2026-02-19
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
orig_trans_no(设备本地交易 ID)定位原始交易。
下图展示了 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
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分钟超时保护
| 状态 | 类别 | 说明 | 看门狗超时 | 触发条件 |
|---|---|---|---|---|
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 |
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分钟超时保护
| 状态 | 类别 | 说明 | 看门狗超时 |
|---|---|---|---|
VoidIdle |
空闲 | 等待 WebSocket VOID 请求 | N/A |
VoidRequestReceived |
处理中 | 收到 VOID 请求,即将执行 | 3 分钟 |
VoidProcessing |
处理中 | CodePay VOID Intent 执行中 | 3 分钟 |
VoidSuccess |
终端 | VOID 成功,卡已退款 | 30 秒 |
VoidFailure |
终端 | VOID 失败(卡拒绝/超时/设备错误) | 30 秒 |
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
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 记录
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
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
系统采用四层纵深防御策略,前三层确保 POS 设备在任何异常情况下都不会永久卡死,第四层防止并发操作导致双重记账:
问题:上一笔支付完成后,如果 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、热重载、系统休眠等干扰后的恢复。
问题:交互状态(客人走开不操作)、处理状态(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、硬件故障、网络中断)。
问题: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、系统级对话框阻塞。
问题: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 节"并发锁机制设计决策"。
除了 POS 端的防护,后端也有独立的超时机制:
触发条件:创建 payment_request 后调度 5 分钟延迟任务。
超时动作:
UPDATE payment_requests SET status = 'timeout' WHERE id = $1 AND status IN ('pending', 'accepted')pos:payment_timeout取消条件:POS 调用 /pos-confirm 成功后取消超时调度。
触发条件:创建 refund (pending) 后调度 5 分钟延迟任务。
超时动作:
UPDATE refunds SET refund_status = 'failed' WHERE id = $1 AND refund_status = 'pending'payment:void_failed取消条件:POS 调用 /pos-void-confirm 成功后取消超时调度。
| 异常场景 | 影响 | 防护层 | 恢复机制 |
|---|---|---|---|
| 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_no:V_{origOrder}_{timestamp} |
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 |
系统在正向流(支付/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
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 支付请求" |
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 加密) |
| 存储 Key | unsynced_payment |
| 存储内容 | { requestId, codepayTransactionId, tipAmount, totalPaid, intentResponse, timestamp } |
| 防护效果 | 有未同步记录时拒绝新请求 → 防止双重扣款 |
| 清除条件 | 重试 confirm 成功后自动清除 |
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 重新连接 |
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 + broadcastToStore(payment: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 |
当支付/VOID 在 CodePay 成功但 3 次 server confirm 全部失败时,POS 显示成功后 5 秒回 idle,未同步的交易信息随之丢失。
已实现双重恢复 + 并发安全:
POST /pos-manual-confirm(FOR UPDATE 锁)补建 transaction → 双通道广播 + SYNC_CLEARED 通知 POS 清除 SecureStorage。无需等待 POS 终端响应。支持 acknowledged 和 timeout 两种状态的请求。SecureStorage(Android Keystore 加密),收到新支付请求时自动检测并重试。重试失败则拒绝新请求,防止双重扣款。CodePay Intent VOID(trans_type: 2)只能在当天结算前执行。过了结算时间的交易会返回 M011 错误。
改进方向:检测 M011 后自动降级为 Cloud Mode Refund(trans_type: 3),或在 Web 端提示用户选择其他退款方式。
当出现 POS-Backend 数据不一致时,可通过以下方式对账:
POST /api/entry/orderquery — 通过 merchant_order_no 查询交易状态| 组件 | 文件路径 | 职责 |
|---|---|---|
| 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 恢复逻辑 |
本节记录 POS 支付系统中引入 PostgreSQL FOR UPDATE 行级锁的决策过程、技术原理和替代方案对比。
POST /pos-confirm 全部失败。此时:
POST /pos-manual-confirm两条路径可能同时到达 Backend:POS 重试的 pos-confirm 和 Web 的 pos-manual-confirm。两者都会读取同一条 payment_request 记录(status = 'acknowledged'),各自创建一条 transaction。
后果:同一笔支付对应两条 transaction,导致双重记账、invoice 重复标记 paid、appointment 支付状态异常。
在 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
FOR UPDATE 在 SELECT 时获取行级写锁,其他事务的 SELECT FOR UPDATE、UPDATE、DELETE 都会阻塞这与普通 SELECT(无 FOR UPDATE)不同——普通 SELECT 在 READ COMMITTED 下不阻塞,两个事务可同时读到 status = 'acknowledged'。
| 方案 | 原理 | 优点 | 缺点 | 是否采用 |
|---|---|---|---|---|
| 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 已足够 |
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
| 端点 / 方法 | 文件 | 锁定条件 | 作用 |
|---|---|---|---|
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 手动确认竞争 |