# EftposPay 功能与支付流程梳理

本文档基于 `packages/private-materials/src/components/eftposPay` 当前代码整理，目标是说明 `EftposPay` 的完整功能、页面流转、每类支付厂商的处理步骤，以及结果、错误、手动标记、恢复和日志如何处理。

## 1. 功能定位

`EftposPay` 是私有物料库中的 EFTPOS 支付容器，对外由 `packages/private-materials/src/index.ts` 导出为 `EftposPay`。它不是单一支付 SDK 的封装，而是一个统一支付入口，按 `props.type` 路由到不同厂商实现：

- `payo`：后端 EFTPOS 支付，支持支付和退款；支付流程使用支付接口与查询接口竞速兜底。
- `tyro`：前端 Tyro SDK 支付，支持支付和退款；由浏览器加载 Tyro iClient 脚本并通过 SDK 回调驱动。
- `windcave`：后端 EFTPOS 支付，支持支付和退款；由后端发起交易，前端轮询动态步骤，支持取消和签名确认。
- `linkly`：后端 Linkly 支付，支持支付和退款；申请交易后按 session 查询，支持取消和签名确认。
- `huifu`：汇付扫码/付款码聚合支付，支付时先扫码或手输授权码，退款直接调用聚合退款。
- `mx51`：MX51 外部统一支付，支付/退款由后端统一接口驱动，前端渲染后端返回的动态操作表单。

`EftposEnum.Stripe` 只在枚举中出现，`manufacturer.tsx` 没有 Stripe 分支，因此当前 `EftposPay` 不支持 `stripe`。

## 2. 对外输入与输出

入口组件是 `index.tsx` 默认导出的 React 组件，核心 props 来自 `const.ts` 的 `PosProps`。

### 2.1 核心输入

- `type`：厂商类型，决定初始化状态和支付实现。
- `mode`：交易模式：
  - `pay`：普通支付。
  - `fullPay`：完整支付，包含金额/手续费确认页。
  - `refund`：退款。
  - `query`：查询/恢复交易。
- `params`：订单、金额、设备、平台、业务扩展参数。入口会把它写入初始 `State`，并额外保留到 `originalParams`。
- `source`：
  - `normal`：正常进入。
  - `restore`：崩溃/刷新恢复，入口直接把 `params` 当作完整 `State` 使用。
- `getApi`：外部注入 API 集合。不同厂商会调用其中不同方法，例如 `pay`、`refund`、`check`、`update`、`getDeviceList`、`addTransaction`、`editTransaction`、`linklyPayQuery`、`linklyRefundQuery` 等。
- `onChangeStatus`：支付组件所有对外事件出口。
- `onChangeParams`：状态缓存出口，`App` 和 `Tip` 会持续把当前 store 状态回传，供外部保存和恢复。
- `terminal.network`：外部网络状态。
- `params.device_number`：宿主设备号，用于设备列表自动筛选与自动选择。
- `params.platform` 和本地 `storage.get('channel')`：用于判断 `terminal/kiosk/web` 渠道，并影响移动端样式和 `client`。

### 2.2 对外状态回调

所有厂商最终通过 `onChangeStatus(status, params, other)` 对外通知。

| status | 含义 | 主要触发点 |
| --- | --- | --- |
| `page` | 当前页面或业务阶段变化 | 入口初始化路由、`App` 内部页面切换、失败页展示等 |
| `success` | 支付/退款成功 | 各厂商成功回调、恢复成功确认页、手动标记成功 |
| `fail` | 支付/退款失败或用户关闭 | 失败页关闭、取消支付、手动标记失败 |
| `print` | 要求外部打印小票 | Tyro、MX51、未知/超时场景的打印兜底 |
| `mark_tx_processed` | 标记历史交易已处理 | 当前启用代码中没有触发，只存在入口交易检查的注释代码中 |

`index.tsx` 会对 `onChangeStatus` 做一层包装，先写支付日志，再调用外部传入的 `props.onChangeStatus`。

## 3. 全局 State

`store/index.tsx` 定义了跨页面状态。关键字段如下：

- `eftpos`：厂商类型。
- `mode`：支付、完整支付、退款、查询。
- `order_id`：订单号。
- `amount`：原始订单金额。
- `symbol`：币种符号。
- `device`：选中的刷卡设备。
- `pay`：金额页或设备页计算出的支付金额结构，包含 `amount`、`surcharge`、`total`、`surRate`、`surMoney`、`surPercentRate`、`totalUnit` 等。
- `number`：交易流水号。多数厂商用 `getUuid()` 生成；Linkly 使用后端返回的 `session_id`；聚合支付使用 `unique_payment_number`。
- `status`：页面总状态，枚举值包括 `init`、`loading`、`warn`、`fail`、`success`、`question` 等。
- `steps`：固定步骤型厂商的步骤列表。
- `custom`：各厂商扩展状态，例如动态步骤列表、签名状态、收据、卡机手续费、重试次数、MX51 动态 action。
- `manual`：手动标记页的标题、说明、金额确认状态。
- `backup`：网络中断时保存的完整状态备份。
- `client`：`merchant` 或 `user`。`channel === 'kiosk'` 时为 `user`，否则为 `merchant`。
- `originalParams`：入口原始参数，用于失败重试时重新初始化，同时保留业务自定义参数。

## 4. 入口初始化与页面路由

入口文件 `index.tsx` 做四件事：初始化日志、确定渠道与移动端、构造初始状态、推导首个页面。

### 4.1 `check` 状态

入口维护 `check`，用于交易检查/恢复场景。

- Tyro 初始为 `OrderEumn.Null`。
- 非 Tyro 且 `source === restore` 时初始为 `OrderEumn.Restore`。
- 其他正常进入初始为 `OrderEumn.Normal`。

当前代码中，非恢复、非 Tyro 的“未处理交易检查”逻辑已被整段注释掉。实际执行时：

- `OrderEumn.Normal` 会先返回 `null`，随后 effect 把 `check` 改成 `Null` 再渲染 `App`。
- `OrderEumn.Succeed` 分支仍保留，会渲染 `Tip` 让用户确认历史成功交易，但当前启用代码不会主动把 `check` 置为 `Succeed`。
- `OrderEumn.Pending` 分支会用 `historyData` 作为 Provider 状态，并强制 `action` 为 `pay`，但当前启用代码也不会主动进入。

注释中的交易检查原本会调用 `api.getTransaction({ order_id, card_reader_type, receive_status: 'unprocessed' })`，并根据最新交易状态做：

- 已失败：调用 `mark_tx_processed` 标记所有相关交易号已处理。
- 有更旧记录：调用 `mark_tx_processed` 标记旧记录。
- 最新成功/待处理：解析 `metadata.pay_param_base64` 恢复历史状态，记录手续费、收据、更新时间，并进入提示或恢复流程。

### 4.2 初始 State

`state` 的构建规则：

1. 如果 `source === restore`，直接使用 `props.params` 作为 `State`。
2. 否则调用 `getInitState(type, params)`：
   - `payo` -> `payo/const.ts`
   - `windcave` -> `windcave/const.tsx`
   - `tyro` -> `tyro/const.tsx`
   - `linkly` -> `linkly/const.ts`
   - `huifu` -> `huifu/const.ts`
   - `mx51` -> `mx51/const.ts`
3. 初始化时会把 `mode`、`symbol` 合并进 `params`，并把原始 `params` 保存为 `originalParams`。

### 4.3 首个页面 `action`

入口按 `mode` 和状态决定首个页面：

- `pay` / `fullPay`：
  - 已有 `state.device` 且已有 `state.pay`：进入 `pay`。
  - 已有 `state.device` 但没有 `pay`：进入 `amount`。
  - 没有设备：进入 `deviceList`。
- `refund`：
  - 已有设备：进入 `pay`。
  - 没有设备：进入 `deviceList`。
- `query`：
  - 直接进入 `pay`。

推导出页面后会立刻对外调用 `onChangeStatus('page', action)`。

## 5. App 层页面切换与缓存

`app.tsx` 接收入口推导出的 `action`，维护当前页面 `current`：

- `deviceList`：渲染 `Device`。
- `amount`：渲染 `Amount`。
- `pay`：渲染 `Pay`。

`App` 内部包装了 `onChange`：

- 如果收到 `onChange('page', 'amount'|'deviceList'|'pay')`，会更新本地 `current`，并写入全局 `state.action`。
- 无论是否页面切换，都会继续向外调用 `props.onChangeStatus`。

`App` 每次全局 `data` 变化都会调用 `onChangeParams`，回传可恢复状态：

- 普通厂商直接回传当前 `data`。
- Tyro 会把 `custom.btnList` 和 `custom.list` 清空后回传，避免把 ReactNode 或临时步骤 UI 缓存出去。
- 回传数据会带 `form: RestoreEumn.Restore`。

## 6. 设备选择页

`device.tsx` 负责选择刷卡设备。

### 6.1 加载设备

进入页面后调用：

```ts
api.getDeviceList({ type: eftpos, status: 'paired' })
```

返回后：

1. 如果有 `params.device_number`，会优先筛选设备中 `setting.devices` 或 `setting.iot_devices` 的 `number` 匹配项。
2. 如果筛选后只有一个设备，自动选择。
3. 如果没有 `device_number`，但列表只有一个设备，也自动选择。
4. 多设备时展示列表，等待用户点击。
5. 空列表或接口失败，展示“没有设备”的提示。

Terminal 环境会在进入设备列表时先隐藏原生关闭按钮，5 秒后如果仍未自动选择才恢复显示；多设备或异常时也会恢复显示。

### 6.2 选择设备

选择设备时：

1. 写日志并设置 `payLog` 的 `deviceId`。
2. `dispatch(updateDevice({ device }))`。
3. 如果外部提供 `api.selectDevice`，调用它。
4. `mode === pay` 时：
   - Terminal 环境先隐藏关闭按钮。
   - 检查 `pay_webview_active`，如果页面已不活跃，阻断支付。
   - 如果设备对象已有 `device.pay`，直接写入 state。
   - 否则调用 `getAmonunt` 和 `getPayAmonunt` 计算金额与手续费。
5. 页面跳转：
   - `fullPay` -> `amount`
   - 其他模式 -> `pay`

设备列表项会展示总金额、手续费、手续费比例和固定金额；MX51 额外展示 `Pairing ID`。

## 7. 金额/手续费页

`amount.tsx` 负责 `fullPay` 或已有设备但未生成 `pay` 的金额确认。

金额数据来自 `getAmonunt(store)`：

- 如果 state 已有 `pay`，使用 `pay` 中的金额和手续费。
- 否则读取设备 `setting.constant_rate`、`setting.constant_money`、`setting.surcharge`。
- Tyro 支持 `setting.useOriginSurcharge`，开启时不使用系统手续费。

页面支持两层编辑：

1. 支付金额确认：
   - 支付金额不能超过订单金额，不能小于 0。
   - 展示剩余现金待付金额。
   - 展示总手续费和实际支付金额。
2. 手续费编辑：
   - 可编辑百分比手续费 `curSurRate`。
   - 可编辑固定手续费 `curSurMoney`。
   - 如果 `useOriginSurcharge` 为真，禁用编辑手续费。

确认时计算并写入 `pay`：

- `amount`：本次 EFTPOS 支付原始金额。
- `surPercentRate`：比例手续费小数形式，例如 `0.0100`。
- `surRate`：比例手续费百分数，例如 `1`。
- `surRateMoney`：比例手续费金额。
- `surMoney`：固定手续费。
- `surcharge`：总手续费。
- `total`：实际支付总额。
- `sourceAmount`：订单原始金额。
- `totalUnit`：格式化后的总额。

之后调用 `onChange('page', 'pay')` 进入支付页。

## 8. Pay 层与通用网络恢复

`pay.tsx` 是支付页容器，按 `state.eftpos` 渲染不同厂商组件，同时负责自定义页切换。

### 8.1 厂商渲染

- `payo` -> `<Payo />`
- `tyro` -> `<Tyro />`
- `windcave` -> `<Windcave />`
- `linkly` -> `<Linkly />`
- `huifu` -> `<Huifu />`
- `mx51` -> `<Mx51 />`

如果 `state.type === 'unset'`，会渲染自定义组件：

- `Fail`
- `FailCustom`
- `Network`
- `Manual`
- `Signature`
- `UnknowFail`

`updateComponent(component, render?)` 控制进入自定义页：

- `component` 非空：`type` 变为 `unset`，自定义页显示。
- `component` 为空：回到步骤页。
- `render` 用于决定原步骤页是否保留在 DOM 中。

### 8.2 网络中断

`App` 结合外部 `terminal.network` 和浏览器 `useNetwork().online` 得到 `net`，`Pay` 监听该值。

断网时：

1. `dispatch(updateState({ form: 'Network' }))`
2. `dispatch(backUpProduction({}))` 备份当前完整 state。
3. `dispatch(updateState({ net, status: 'warn' }))`
4. `dispatch(updateComponent('Network'))` 进入断网页。

网络恢复时：

1. 如果存在 `backup`，通过 `backUpFree({ key: Date.now() })` 恢复。
2. 更新 `net`。
3. 如果恢复出的状态已经是 `success`，1 秒后构造 `getPayParams(data, 'success')` 并触发 `onChange('success', params)`。

## 9. 成功参数构造

所有厂商成功后基本都会调用 `getPayParams(data, 'success')`。

支付/完整支付成功参数包括：

- 原 `pay` 中的金额字段。
- `cardReaderSurcharge`：卡机侧额外手续费。
- `surMoney`：系统固定手续费 + 卡机额外手续费。
- `surcharge`：固定手续费 + 比例手续费 + 卡机额外手续费。
- `total`：支付金额 + 最终手续费。
- `number` / `uniquePaymentNumber`
- `device`
- `order_id`
- `receipt`

如果 `device.setting.receipts` 为真，表示小票由设备处理，回调里的 `receipt` 会被置为空数组。

退款成功参数包括：

- `amount`
- `total`
- `number` / `uniquePaymentNumber`
- `device`
- `order_id`
- `receipt`

失败参数通常包括：

- `number`
- `uniquePaymentNumber`
- `device`
- `order_id`

## 10. Payo 流程

Payo 是固定步骤流程，初始化时只有一个 loading step。

### 10.1 支付流程

文件：`payo/config.tsx`、`payo/payment.ts`

1. 进入 Payo POS 组件。
2. 如果 state 已有 `number`，认为是恢复/查询，直接调用 `checkApi()`。
3. 如果没有 `number`：
   - 生成 UUID。
   - 写入 `state.number`。
   - 构造支付参数：
     - `order_id`
     - `amount: pay.total`
     - `original_amount: pay.amount`
     - `service_charge.amount: pay.surMoney`
     - `service_charge.percentage: pay.surPercentRate`
     - `card_reader_type`
     - `card_reader_id`
     - `number`
     - `pay_param_base64`：当前 state 编码，用于后续恢复。
     - 如果有 `custom.platform`，额外传 `operator_id`、`operator_type`、`platform`、`custom_payment_id`。
4. 调用 `usePayment().run()`：
   - 立即发起 `api.pay(params)`。
   - 10 秒后开始轮询 `api.check({ number })`。
   - 支付接口和查询接口谁先返回明确成功，就走成功。
   - 明确错误码且不是超时类错误，直接失败。
   - 无明确错误、请求超时、Abort 或网关超时，继续轮询。
   - 轮询全局 2 分钟超时后返回 `PayStatus.Timeout`。
5. 成功时：
   - 调用 `updateNextStep()` 把步骤置为成功。
   - 保存 `card_reader_surcharge` 和 `receipt` 到 `custom`。
   - 1 秒后调用 `onChange('success', getPayParams(...))`。

### 10.2 退款流程

退款不使用 `usePayment` 的支付/查询竞速，而是直接调用 `api.refund(params)`：

1. 构造退款参数：
   - `order_id`
   - `amount: amountRef.current`
   - `card_reader_type`
   - `card_reader_id`
   - `number`
   - `original_payment_number`
   - `pay_param_base64`
2. 调用 `api.refund`。
3. 返回 `code === 200` 则成功。
4. 如果失败且没有 `code`，或 `code === PayStatus.Timeout`，转入 `checkApi()` 查询。
5. 其他失败交给通用 `useFail(index=0, isMark=true)`。

### 10.3 Payo 操作按钮

`Action` 组件：

- 30 秒后对用户端显示取消按钮，点击后直接 `onChange('fail', ...)`。
- 商家端一直显示“手动标记”，点击后进入 `Manual`。

## 11. Tyro 流程

Tyro 是前端 SDK 流程，文件：`tyro/index.tsx`、`tyro/hooks.tsx`。

### 11.1 SDK 加载

`useTyro({ url, version })` 动态加载脚本：

- 如果传入 `tyroUrl` 和 `tyroVersion`，加载 `${url}/${version}`。
- 否则加载测试地址 `https://iclientsimulator.test.tyro.com/iclient-v1.js`。

脚本加载完成后，从 `window.TYRO` 取 `IClient`。

### 11.2 终端信息检查

初始化 `new IClient(apiKey, posProductData)` 后，先调用：

```ts
iClient.terminalInfo(callback, { mid, tid, integrationKey })
```

只有 `status === 'success'` 且 `terminalInfo.available` 时才继续发起交易。

### 11.3 支付流程

1. 生成 UUID 写入 `state.number`。
2. 构造 `requestParams`：
   - `amount: pay.total * 100`，Tyro 以分为单位。
   - `enableSurcharge: device.setting.useOriginSurcharge`
   - `integratedReceipt: !device.setting.receipts`
   - `integrationKey`
   - `transactionId: number`
   - `mid`
   - `tid`
   - `device: JSON.stringify(device)`
   - `isPrintMerchant`
   - `isPrintCustomer`
   - `browserVersion`
3. 先调用 `api.addTransaction` 写一条 pending 交易记录。
4. 记录返回的 `requestId` 到 `custom.requestId`。
5. 调用：

```ts
iClient.initiatePurchase(requestParams, {
  receiptCallback,
  transactionCompleteCallback,
  questionCallback,
  statusMessageCallback,
})
```

### 11.4 退款流程

退款与支付类似，但：

- `amount` 使用 `amountRef.current * 100`。
- 调用 `iClient.initiateRefund`。
- `api.addTransaction` 的 `action` 为 `refund`。

### 11.5 恢复上次交易

如果进入 Tyro 时已有 `number`，不会重新生成交易，而是调用：

```ts
iClient.continueLastTransaction(callbacks)
```

用于从 SDK 的上次交易状态继续。

### 11.6 Tyro 回调处理

#### `receiptCallback`

- 保存商家小票和 `signatureRequired`。
- 如果需要签名，立即 `onChange('print', receipt)`。
- 如果不需要签名，但设备配置允许打印商户小票，也触发 `print`。

#### `statusMessageCallback`

- 将 Tyro SDK 的英文 message 翻译成本地文案。
- 追加到 `custom.list`，用 Step 组件展示过程。
- 对 “Connection lost. Attempting to recover” 做去重。

#### `questionCallback`

- SDK 要求用户选择时触发。
- 按 `question.options` 生成按钮，保存到 `custom.btnList`。
- 点击按钮后：
  - 清空 `btnList`。
  - 状态改回 `loading`。
  - 调用 `answerCallback(item)`。
- 如果 `question.isError` 为真，页面状态为 `fail`；否则为 `warn`。

#### `transactionCompleteCallback`

1. 调用 `api.editTransaction` 更新交易记录：
   - `status: APPROVED ? succeed : failed`
   - 保存 request/response/receipt。
2. 清空 `btnList`。
3. 如果 `response.result === 'APPROVED'`：
   - state 改成 `success`。
   - 保存 `response.surchargeAmount` 和客户小票。
   - 1 秒后 `onChange('success', getPayParams(...))`。
4. 如果不是成功，但客户小票需要打印，触发 `onChange('print', receipt)`。
5. 如果 `response.result === 'UNKNOWN'`，进入 `UnknowFail`。
6. 其他失败进入 `FailCustom`，展示 `response.result`。

### 11.7 Tyro 超时与取消

- 查询终端信息阶段 `status === 0`，1 分钟无结果则进入 `FailCustom`，提示连接/支付超时。
- 交易发起后 1 分钟仍未完成，显示手动操作：
  - 商家端：显示“手动标记”，进入 `Manual`。
  - 用户端：显示“打印小票”，触发 `onChange('print', [], 'print_on_timeout')`。
- 点击取消：
  - 如果交易已开始，调用 `iClient.cancelCurrentTransaction()`。
  - 如果交易未开始，直接进入 `FailCustom` 取消失败页。

## 12. Windcave 流程

Windcave 是动态步骤流程，文件：`windcave/windcave.tsx`、`windcave/helper.tsx`、`windcave/receiptAction.tsx`。

### 12.1 步骤模型

Windcave 的 `custom.list` 动态维护步骤，`custom.step` 表示当前步骤。

`StepStatusEnum`：

- `Free = 1`：等待用户在设备上操作。
- `Card = 2`：提交或插入卡。
- `Account = 3`：选择账户。
- `Application = 4`：选择应用。
- `Pin = 5`：输入 PIN。
- `Processing = 6`：处理中。
- `Receipt = 7`：验证签名。
- `Wait = 8`：等待最终结果。

初始化时如果 `custom.list` 为空，会写入 `Free` 步骤。

### 12.2 支付/退款发起

1. 如果当前已成功或失败，直接返回。
2. 如果处于签名步骤：
   - 如果状态是 `loading`，说明签名确认/取消接口正在处理，启动计时查询。
   - 否则启动无限查询。
3. 如果已有 `number`，直接启动计时查询。
4. 没有 `number` 时：
   - 生成 UUID。
   - 写入 `state.number`。
   - 支付时构造：
     - `service_charge`
     - `original_amount`
     - `order_id`
     - `amount: pay.total`
     - `card_reader_type`
     - `card_reader_id`
     - `number`
     - `pay_param_base64`
   - 退款时构造：
     - `order_id`
     - `amount`
     - `card_reader_type`
     - `card_reader_id`
     - `number`
     - `pay_param_base64`
   - 调用 `api.pay` 或 `api.refund`。
5. 接口返回 `code === 200` 且有 `data.txn_status_id` 时，交给 `useValidate` 继续轮询。
6. 否则进入 `useFail`。

### 12.3 轮询与动态步骤

`useValidate(api.check, 1分钟, onSuccess)` 使用 `XhrTimer` 轮询：

- 默认单轮整体 1 分钟超时。
- 每次请求最小间隔 5 秒。
- 如果传 `run(-1)`，表示无限查询，不设置整体超时。

轮询返回时：

- `txn_status_id === Wait`、`status_id === 6`、`complete === 1`：视为最终成功。
- 如果返回新 `txn_status_id`：
  - 把旧步骤标为 `resolve`。
  - 按 `getStepMapping` 增加新步骤。
  - 如果新步骤是 `Passive`，页面状态改为 `question`。
  - 如果新步骤是 `Receipt`，标题改成等待签名。
  - `Receipt` 启动无限查询，否则启动计时查询。
- 如果没有新步骤且仍在运行，继续查询。

### 12.4 Windcave 成功

成功时：

1. 把 `custom.list` 所有步骤标为 `resolve`。
2. 保存 `card_reader_surcharge` 和 `receipt`。
3. state 改成 `success`，标题改为成功文案。
4. 1 秒后 `onChange('success', getPayParams(...))`。

### 12.5 取消与签名

取消支付：

- 当当前步骤小于 `Application` 时显示取消按钮。
- 点击后调用：

```ts
api.update({ type: 'CANCEL', number })
```

签名步骤：

- `ReceiptAction` 展示“拒绝/接受”。
- 接受签名：
  - 当前签名步骤标为 `resolve`。
  - 状态改为 `loading`。
  - 调用 `api.update({ type: 'YES', number })`。
  - 成功后进入 `Wait` 步骤并重新轮询。
  - 失败则回到签名等待状态。
- 拒绝签名：
  - 先进入二次确认页。
  - 确认后调用 `api.update({ type: 'NO', number })`。
  - 成功后签名步骤标为拒绝但流程继续进入 `Wait`，由后端最终决定交易结果。

## 13. Linkly 流程

Linkly 文件：`linkly/index.tsx`、`linkly/hooks/normal.ts`、`linkly/hooks/useTimeQuery.ts`、`linkly/service.ts`。

### 13.1 支付/退款申请

初始化时：

1. 如果已成功或失败，直接返回。
2. 如果已有 `number`，直接启动结果查询。
3. Terminal 环境会先检查 `pay_webview_active`，不活跃则阻断支付。
4. 调用 `payOrRefund(resultQuery, handleLinklySuccess)`。

申请接口：

- 支付：`POST /shop/linkly/pay/apply`
- 退款：`POST /shop/linkly/refund/apply`

参数：

- `order_id`
- `pinpad_id: device.metadata.linkly_pinpad_id`
- `amount: pay.total` 或退款金额

### 13.2 申请结果处理

接口返回 `data.status`：

- `0`：未知/处理中。
  - 把 `data.session_id` 写入 `state.number`。
  - 设置 `custom.actionStatus = Cancel`，展示取消按钮。
  - 300ms 后启动结果查询。
- `1`：成功，直接进入成功处理。
- `< 0`：失败，把 `res.code` 转成 `PayStatus.Unknown`，走通用失败。

申请接口失败时直接走通用失败。

### 13.3 结果查询

查询接口：

- 支付：`api.linklyPayQuery` 或 `/shop/linkly/pay/query`
- 退款：`api.linklyRefundQuery` 或 `/shop/linkly/refund/query`

查询机制：

- 全局超时 2 分钟。
- 单次请求超时 5 秒。
- 轮询间隔 2 秒。
- 单次请求 Abort、超时或网关错误会继续查询。

查询结果：

- `status === 1`：成功，停止轮询。
- `status === 0 && signature_flag !== 1`：继续查询。
- `status < 0`：失败，转 `PayStatus.Unknown`，不可手动标记。
- `signature_flag === 1`：进入签名状态。

### 13.4 Linkly 取消与签名

取消：

- 点击取消时先停止当前查询。
- 调用：
  - 支付取消：`POST /shop/linkly/pay/cancel`
  - 退款取消：`POST /shop/linkly/refund/cancel`
- 不管取消接口成功还是失败，都重新调用结果查询。
- 取消中 `custom.actionStatus = CancelWaiting`，按钮显示不可离开。

签名：

- 接受签名传 `signature_flag = 3`。
- 拒绝签名传 `signature_flag = 2`。
- 调用：
  - 支付签名：`POST /shop/linkly/pay/signature`
  - 退款签名：`POST /shop/linkly/refund/signature`
- 签名中 `custom.actionStatus = SignatureWaiting`。
- 签名接口结束后继续查询结果。

### 13.5 Linkly 成功

成功时：

1. 整理 `res.data.receipt`。
2. `updateNextStep()`。
3. 保存 `card_reader_surcharge` 和 `receipt`。
4. state 改成 `success`。
5. 1 秒后 `onChange('success', getPayParams(...))`。

## 14. Huifu 流程

Huifu 文件：`huifu/index.tsx`、`huifu/Action.tsx`、`huifu/const.ts`，底层使用 `aggregatePayment/useMicropay`。

### 14.1 初始状态

Huifu 初始化时区分支付和退款：

- 支付或完整支付：`status = init`，标题为空，先展示扫码/手输付款码页面。
- 退款：`status = loading`，直接进入聚合退款流程。

### 14.2 支付流程

1. 进入 `ScanAction`。
2. 展示扫码枪、摄像头状态和手输输入框。
3. 原生 App 场景：
   - `status === init` 时显示原生关闭按钮。
   - 进入 loading 后隐藏原生关闭按钮。
4. 扫码枪回调或手动输入 18 位付款码后：
   - `updateToLoading()`。
   - 调用 `payment.run({ authCode })`。
5. `useMicropay` 构造聚合支付参数：
   - `order_id`
   - `unique_payment_number`
   - `payment_code`，默认 `EFTPOS_HUIFU`，也可从 `custom.custom_payment_code` 读取。
   - `amount`
   - `operator_id/operator_type/platform`
   - `micropay.card_reader_type`
   - `micropay.card_reader_id`
   - `micropay.auth_code`
   - 支付场景额外传 `payment`，包含原金额、业务支付方式、手续费和 metadata。
6. 底层 `PaymentMethod` 发起 `/shop/pay/external-unified/pay` 并轮询 `/shop/pay/external-unified/pay/verify`。

### 14.3 退款流程

退款时没有扫码页，初始化后直接：

```ts
payment.run({ orderPaymentId: custom.order_payment_id })
```

`useMicropay` 会把 `micropay.order_payment_id` 传给聚合退款，并调用 `/shop/pay/external-unified/refund` 和 `/shop/pay/external-unified/refund/verify`。

### 14.4 Huifu 结果

聚合回调：

- `EPaymentStatus.Success`：
  - `updateNextStep()`。
  - 保存 `card_reader_surcharge: 0` 和空小票。
  - state 改成 `success`。
  - 1 秒后 `onChange('success', getPayParams(...), receipt: [])`。
- `EPaymentStatus.Failed`：
  - 调用通用 `useFail(0)`。

取消扫码页会直接 `onChange('fail', ...)`。

## 15. MX51 流程

MX51 文件：`mx51/index.tsx`、`mx51/Action.tsx`、`mx51/utils.ts`，底层使用 `aggregatePayment/usePayment`。

### 15.1 初始化和聚合参数

进入时：

1. 如果状态已经是 `success`、`fail` 或 `question`，不重复发起。
2. 如果已有 `number`，调用 `payment.query()` 恢复查询。
3. 否则调用 `payment.run()`。

`usePayment` 会构造外部统一支付参数：

- `order_id`
- `unique_payment_number`
- `payment_code: EFTPOS_MX51`
- `amount`
- `operator_id/operator_type/platform`
- `eftpos.card_reader_type`
- `eftpos.card_reader_id`
- `eftpos.pay_param_base64`
- 支付场景额外传 `payment`：
  - `original_amount`
  - `order_payment_type`
  - `custom_payment_id/code/name/type`
  - `service_charge`
  - `metadata.unique_payment_number`
- 退款场景如有 `originalParams.refund_payment_context`，会给其中每个 payment 注入 `metadata.unique_refund_number`。

底层接口：

- 支付：`POST /shop/pay/external-unified/pay`
- 支付查询：`POST /shop/pay/external-unified/pay/verify`
- 支付行为：`POST /shop/pay/external-unified/pay/action`
- 退款：`POST /shop/pay/external-unified/refund`
- 退款查询：`POST /shop/pay/external-unified/refund/verify`
- 退款行为：`POST /shop/pay/external-unified/refund/action`

MX51 配置：

- 不开启查询间隔，查询会立即连续发起。
- 全局查询超时 2 分钟。
- `delay_seconds = 130` 作为后端兜底查询时间。

### 15.2 处理外部统一回调

`PaymentMethod` 会把返回转成三类状态：

- `processing`
- `success`
- `failed`

MX51 收到回调后解析：

```ts
parseMX51Params(transaction, status, data)
```

解析内容包括：

- `message`
- `merchantReceipt`
- `customerReceipt`
- `resultAmounts`
- `resultFinancialStatus`
- `pos_instructions.auto_actions`
- `pos_instructions.action_form.layout`
- `pos_instructions.action_form.properties`
- 动态按钮、文本、图片、输入框、提交 URL。

解析结果写入 `custom.customAction`，由 `mx51/Action.tsx` 动态渲染。

### 15.3 Processing

如果 `status === processing`：

- 如果后端 action 是 `signature`：
  - 页面状态改为 `question`。
  - 标题改成 `message`。
  - 停止聚合轮询。
  - 如果 `autoAction` 包含 `PRINT_MERCHANT_RECEIPT`，触发 `onChange('print', [merchantReceipt])`。
- 其他 processing：
  - 页面改为 loading。
  - 继续等待底层查询/回调。

### 15.4 Success

成功时：

1. 从 `external_unified_response.transaction` 读取商户/客户小票。
2. 按设备配置判断是否需要由系统打印：
   - `print_merchant_receipt` 为假且有商户小票，加入回调小票。
   - `prompt_customer_receipt` 为假且有客户小票，加入回调小票。
3. 读取 `external_service_fee` 作为 `card_reader_surcharge`。
4. `updateNextStep()`。
5. 保存手续费和小票。
6. state 改成 `success`。
7. 1 秒后 `onChange('success', getPayParams(...))`。

### 15.5 Failed

失败分两类：

- 如果能从 MX51 返回里解析出动态 action，说明第三方给了可操作 UI：
  - state 改为 `fail`。
  - 标题改为返回 message。
  - 继续渲染动态 action，让用户可重试、打印、完成等。
- 如果没有可解析 action：
  - `handleFail` 从 `external_unified_response.response_code` 和 `message` 构造错误。
  - 交给通用 `useFail(0, true)`。

### 15.6 MX51 动态按钮

`EButtonKeyType` 按钮处理：

- `cancel_transaction`：调用 `payment.action({ submit_url })`。同时启动 20 秒操作超时；超时后停止流程并用 `PayStatus.Timeout` 进入可标记失败。
- `decline_signature`：调用 action。
- `approve_signature`：调用 action。
- `print_merchant_receipt`：触发 `onChange('print', [receipt])` 并弹窗展示小票。
- `print_customer_receipt`：同上。
- `retry_transaction`：
  - `onChange('page', 'pay')`。
  - 清空 `number`。
  - 用 `getInitState` 重新初始化。
- `transaction_complete`：
  - 如果 `_extra.paymentStatus === success`，构造成功参数并 `onChange('success')`。
  - 否则 `onChange('fail')`。
- `submit_to_api`：
  - 收集输入框值，调用 `payment.action({ submit_url, payload }, { needResponse: true })`。
- `call_test_function`：弹出测试 toast。
- 其他带 `submit_url` 的按钮：调用 `payment.action({ submit_url })`。

为避免重复点击，签名、重试、提交等按钮会加 1 秒点击锁，并先清空 `customAction`、切回 loading。

## 16. 通用聚合支付底层

`aggregatePayment/utils/payment.ts` 是 Huifu 和 MX51 共用底层。

核心机制：

1. 发起支付/退款/action 时生成本次 `runId`，防止旧响应覆盖新流程。
2. 发起主请求，同时 10 秒后启动查询。
3. 如果主请求直接返回成功，立即成功并清理。
4. 如果主请求返回 processing 或超时类错误，开始查询。
5. 查询使用 `request_version` 防止过期查询响应被处理。
6. 查询全局超时默认 2 分钟。
7. 单次查询超时默认 10 秒，超时后继续下一轮。
8. 明确业务失败且不是超时类错误，直接失败。
9. `action` 请求通常不直接使用返回，而是 action 返回后继续查询；`needResponse` 为真时才直接处理 action 返回。
10. 成功、失败、停止或销毁都会清理定时器和 AbortController。

## 17. 错误处理总览

错误处理有两套：

- 固定步骤通用错误：`hooks.tsx` 的 `useFail(index)`，Payo、Linkly、Huifu、MX51 部分场景使用。
- Windcave 动态错误：`windcave/helper.tsx` 的 `useFail()`，输出 `FailCustom`。

### 17.1 通用错误码

`PayStatus` 定义：

- `200`：成功。
- `4004`：未生成交易记录。
- `606020`：配对失效。
- `701000`：未知支付失败。
- `701001`：交易请求超时。
- `701002`：支付失败，主要用于汇付。
- `701003`：终端繁忙。
- `701004`：卡片/余额/拒绝/过期/锁定等卡错误。
- `701005`：网络或 websocket 发送失败。
- `701006`：交易等待超时。
- `701007`：连接失败。
- `701008`：银行拒绝。
- `701009`：签名拒绝。
- `701010`：用户配置错误。
- `702001`：MX51 未知状态。

### 17.2 固定步骤 `useFail(index)` 行为

- 无 `res.code`：进入 `UnknowFail`。
- `701000 Unknown`：当前步骤 fail，进入 `Fail`，不可标记。
- `701006 PayTimeout`：当前步骤 fail，进入 `Fail`，不可标记。
- `701001 Timeout`：
  - `isMark === true` 且 `client === merchant`：进入 `Manual`。
  - `isMark === true` 且 `client === user`：进入 `Fail`，提示 POS 网络问题，用户端提供打印兜底。
  - `isMark === false`：进入普通超时失败页。
- `4004 NoPay`：
  - 最多重试 2 次左右：清空 `number`，更新 `custom.retry`，刷新 `key` 重新发起。
  - 超过次数：进入 `Fail`。
- `701003 PayOtherEftposFailed`：终端繁忙失败。
- `701004 PayCardErrorFailed`：卡片/取消相关失败。
- `701005 NoNetWork` / `701007 SocketError`：
  - 当前步骤 warn。
  - 标记 `isPosNetworkError: true`。
  - 进入 `Fail`。
  - 商家端可手动标记，用户端可打印兜底。
- `701008 PayBankRefuses`：银行拒绝失败。
- `701009 SignatureDeclined`：签名拒绝失败。
- `701010 UserConfigError` / `606020 PairingFailure`：配置/配对失败。
- `701002 PaymentFailed`：展示后端 message 或通用失败文案。
- 其他：进入 `UnknowFail`。

### 17.3 Windcave `useFail()` 行为

Windcave 逻辑与通用 `useFail` 类似，但输出写入 `custom.failCustom` 并进入 `FailCustom`，适配动态步骤。

关键差异：

- `4004 NoPay` 会清空 `number`、清空 `custom.list`、刷新 `key` 重新发起。
- `Timeout` 且可标记：
  - 商家端进入 `Manual`。
  - 用户端进入 `FailCustom`，提示网络/终端异常。
- `NoNetWork` / `SocketError` 进入 `FailCustom`，设置 `isPosNetworkError: true`。
- 其他未知错误进入 `UnknowFail`。

## 18. 失败页、未知页、手动标记

### 18.1 `Fail`

普通固定步骤失败页。

- mount 时如果当前步骤状态是 `fail`，触发 `onChange('page', 'fail')`。
- 普通失败：
  - 取消/关闭：`onChange('fail', { number, uniquePaymentNumber, device, order_id })`
  - 重试：`onChange('page', 'pay')`，清空 `number`，用 `getInitState` 重新初始化。
- POS 网络类 warn：
  - 商家端：进入 `Manual`。
  - 用户端：`onChange('print', [], 'print_on_timeout')`。

### 18.2 `FailCustom`

动态失败页，主要用于 Windcave 和 Tyro/MX51 自定义失败。

- mount 时如果 `failCustom.status === 'fail'`，触发 `onChange('page', 'fail')`。
- 普通失败提供关闭和重试。
- 网络类失败提供手动标记或打印兜底。
- 查询模式重试时不会清空 `number`，其他模式会清空。

### 18.3 `UnknowFail`

未知状态页。

- 商家端展示未知状态说明，提供“手动标记”。
- 用户端展示终端异常提示，提供“打印小票”。
- 如果是从 `Network` 或 `restore` 恢复到此页，会自动 `updateComponent('')` 回到步骤流程。

### 18.4 `Network`

客户端断网页。

- 断网后由 `Pay` 自动进入。
- 只展示说明和“重新连接”按钮。
- 点击重新连接只显示 loading message，不主动重试接口。
- 实际恢复由 `Pay` 监听 `net` 变化完成。

### 18.5 `Manual`

手动标记页用于“交易状态无法确定，但商家能从实际设备/小票确认结果”的场景。

初始页有两个选择：

- 标记成功。
- 标记失败。

标记失败：

```ts
onChange('fail', { number, uniquePaymentNumber: number, device, order_id })
```

退款标记成功：

```ts
onChange('success', {
  amount,
  total: amount,
  number,
  uniquePaymentNumber: number,
  device,
  order_id,
  manual_marking_flag: 1,
})
```

支付标记成功：

1. 先进入金额确认页。
2. 默认金额为 `pay.total`。
3. 用户只能输入大于等于原 `pay.total` 的实付金额。
4. 如果实付金额大于原金额，差额会加到 `surMoney` 和 `surcharge`。
5. 确认后：

```ts
onChange('success', {
  ...pay,
  surMoney,
  surcharge,
  total,
  totalUnit,
  number,
  uniquePaymentNumber: number,
  device,
  order_id,
  manual_marking_flag: 1,
})
```

## 19. 恢复与历史成功确认

### 19.1 `source === restore`

如果外部用恢复模式进入：

- 入口直接把 `props.params` 当作完整 `State`。
- 初始 `check` 为 `Restore`。
- 随后渲染 `App`，并按恢复 state 中的 `action` 或入口路由规则继续。

### 19.2 `pay_param_base64`

多个后端厂商发起支付时会把当前 state 编码到请求参数：

```ts
pay_param_base64 = encodeURIComponent(
  btoa(encodeURIComponent(JSON.stringify({ ...dataRef.current, number })))
)
```

用途是后端交易记录保存前端状态，后续可用来恢复 UI 流程。当前入口中解析未处理交易的代码已注释，但各厂商仍会继续提交该字段。

### 19.3 `Tip` 历史成功确认页

如果入口 `check === OrderEumn.Succeed`，会渲染 `Tip`：

1. 根据历史交易恢复的 state 和 `metadata` 构造成功参数。
2. 对 Payo 调 `updateNextStep()`，对 Windcave 把 `custom.list` 全部标为 `resolve`。
3. 保存 `card_reader_surcharge`、`receipt`。
4. state 改为 `success`。
5. 用户点击确认后触发：

```ts
onChangeStatus('success', params)
```

当前代码不会主动进入该分支，因为交易检查逻辑已注释。

## 20. 打印处理

外部打印统一通过：

```ts
onChangeStatus('print', receiptArray, optionalReason)
```

主要场景：

- Tyro：
  - 商户小票回调时，如果需要签名或设备配置允许系统打印商户小票，触发 `print`。
  - 交易失败但有客户小票时，按配置触发客户小票打印。
  - 用户端超时兜底触发 `print`，第三参为 `print_on_timeout`。
- MX51：
  - Processing + signature 且 `autoAction` 包含 `PRINT_MERCHANT_RECEIPT`，自动打印商户小票。
  - 点击打印商户/客户小票按钮时触发 `print` 并弹窗展示。
- 未知状态/网络异常用户端：
  - 点击“打印小票”触发 `print`，第三参为 `print_on_timeout`。
- 成功参数中的 `receipt`：
  - 如果设备配置表示由设备打印，回调中清空。
  - 否则传给外部业务保存/打印。

## 21. 日志处理

`utils/payLog.ts` 是 EFTPOS 支付日志单例。

入口调用 `usePayLog()`：

- mount 时初始化 session。
- unmount 时 flush 并销毁。
- 日志保存在 `localStorage` 的 `pisell2_eftpos_paylog`。
- 新 session 初始化时会恢复上报上次残留的孤儿日志。

日志上下文包括：

- `channel`
- `orderId`
- `deviceId`
- `paymentType`
- `transactionNumber`
- `mode`
- `extra`

日志会上报到 `sendWarningLog`：

- 普通日志会压缩连续重复项。
- 包含失败、错误、异常、error、fail、timeout、超时等关键词的日志不会压缩。
- `pay_param_base64` 等大字段在聚合支付日志中会被清理，避免日志过大。

## 22. 页面流总结

### 22.1 普通支付 `pay`

1. 入口初始化 state。
2. 根据是否已有设备决定进入 `deviceList` 或 `pay`。
3. 设备页加载 paired 设备。
4. 自动选择或用户选择设备。
5. 计算 `pay` 金额。
6. 进入支付页。
7. 按厂商发起交易。
8. 成功：
   - 状态改为 `success`。
   - 保存手续费和小票。
   - 1 秒后 `onChangeStatus('success', params)`。
9. 失败：
   - 进入 `Fail` / `FailCustom` / `UnknowFail` / `Manual`。
   - 用户关闭、重试、手动标记或打印兜底。

### 22.2 完整支付 `fullPay`

1. 入口初始化 state。
2. 如果没有设备，先进入设备页。
3. 设备选定后进入 `amount`。
4. 用户确认支付金额和手续费。
5. 生成 `pay`。
6. 进入支付页。
7. 后续同普通支付。

### 22.3 退款 `refund`

1. 入口初始化 state。
2. 如果没有设备，先进入设备页。
3. 设备选定后直接进入支付页，不进入金额页。
4. 厂商按退款逻辑发起交易。
5. 成功回调金额使用 `amount`。
6. 失败逻辑同支付。

### 22.4 查询/恢复 `query`

1. 入口直接进入支付页。
2. 厂商根据已有 `number` 或恢复 state 执行查询/继续交易。
3. 成功/失败按厂商结果处理。

## 23. 需要注意的当前实现细节

- `mark_tx_processed` 当前只存在注释掉的历史交易检查逻辑中，实际运行不会触发。
- `source === restore` 依赖外部传入完整且正确的 `State`，入口不会校验字段完整性。
- 多数后端厂商仍提交 `pay_param_base64`，但入口的自动未处理交易恢复逻辑目前被注释。
- Tyro 的交易记录由前端显式 `addTransaction` / `editTransaction`，其他后端厂商多由后端接口内部处理。
- Payo 与聚合支付都有“主请求 + 延迟轮询”兜底，但实现是两套。
- `StatusEnum.Pedding` 和 `StatusEnum.Resove` 拼写保留了历史拼写，动态步骤中也有 `pedding` 字段。
- `EftposEnum.Stripe` 没有实际实现分支。
