链上对账的粒度是 chainName+txHash+logIndex,而不是 txHash:Web3 支付自动结算的幂等与阻断边界
文档地址:
Web3 支付自动结算只解决一件事:当本地订单仍未支付,且链上交易与订单期望完全匹配时,在同一个事务内把订单和流水更新为已支付。它不发起链上交易、不托管私钥、不广播交易、不改写链上状态,也不处理退款和争议。
对账粒度必须落在 ERC-20 Transfer 事件上,唯一坐标为 chainName + transactionHash + logIndex。交易 Hash 只提供其中的一个维度,单靠它会漏账。
当前边界
已具备:Payment web3 Provider 创建链上收款上下文、查询交易确认状态;后台单笔与批量只读确认、已结算订单链上二次审计;ERC-20 Transfer 事件匹配、结算 dry-run 计划、计划检查项与 reviewFingerprint;人工复核日志;基于复核日志的显式结算执行器;超时自动通过、自动重试与失败通知;订单状态条件更新与 Transfer 事件唯一性指纹。
明确不做:不托管私钥、不签名、不广播;dry-run 和复核日志阶段不写成功订单;超时自动处理只有在 auto-approve 且 execute-approved=true 时才复用显式结算执行器写订单和流水;不自动退款;不处理跨链桥与非 EVM 链。
后续增强方向是更细的风控策略、链重组工单化处理和真实生产联调。
状态机:ready 只代表可以进入结算事务
结算过程在 Service 内部使用以下候选状态,不一定单独建表:
| 状态 | 含义 |
|---|---|
| pending | 本地订单未支付,等待链上交易 |
| confirming | 交易已发现,但确认数不足 |
| review_required | 链上确认满足基础条件,但缺少事件匹配上下文或需要人工复核 |
| ready | 链上交易、金额、收款地址、Token 合约和本地订单状态都匹配 |
| settling | 正在事务内写入订单和流水 |
| settled | 已完成本地订单和流水更新 |
| blocked | 存在阻断原因,不允许结算 |
| failed | 查询或结算过程失败,需要人工处理 |
四条规则:不能只凭交易 Hash 标记订单成功;终态订单不能回退到未支付;本地已支付、已退款、已关闭的订单不能再次自动结算;ready 只代表可以进入结算事务,不代表已经结算成功。
第三条和第四条都指向同一个判据——事件级唯一性。一笔 tx 里可以有多条 Transfer,批量转账、合约批量 payout、聚合器路由都会让同一条 transactionHash 下出现多个 logIndex。只按 txHash 去重,同一笔交易里的第二笔收款会被当成“已处理”直接跳过;同一 txHash 内 logIndex 发生变化,之前算出的复核结果也随之失效。指纹里必须纳入 Transfer.blockNumber 和 Transfer.logIndex,原因就在这里。
结算前置检查
进入结算事务前,以下检查必须全部通过:
- 订单存在:
outTradeNo能查到本地支付订单 - 支付形态正确:订单 provider 是 web3
- 本地未支付:本地状态仍为待支付或待确认
- 链配置有效:
chainName存在且启用 - 链 ID 匹配:RPC 返回的 chainId 与配置一致
- 交易存在:交易 Hash 格式合法,RPC 能查到交易或回执
- 交易成功:回执 status 成功
- 确认数满足:当前区块高度达到
requiredConfirmations - 收款地址匹配:
Transfer.to与订单收款地址一致 - Token 合约匹配:事件合约地址与订单期望的 Token 合约一致
- 金额匹配:
Transfer.value与订单amountRaw精确相等 - 事件唯一:
chainName + transactionHash + logIndex未被消费 - 复核一致:提交的
reviewFingerprint与服务端重新计算的结果一致
任意一项不通过,只能返回阻断原因,不能写订单状态。
三层幂等
| 层级 | 推荐 key | 用途 |
|---|---|---|
| 链上事件 | chainName:transactionHash:logIndex | 防止同一笔转账事件重复结算 |
| 业务订单 | outTradeNo | 防止同一订单被重复变更状态 |
| 人工复核 | reviewFingerprint | 防止操作员提交过期页面结果;指纹包含 Transfer blockNumber、logIndex 和匹配原因码 |
结算事务的流程大致如下:
- 重新生成单笔结算计划,再校验规则不变的
reviewFingerprint - 再次判断订单状态是否仍允许结算
- 用条件更新执行
pending -> success,不依赖SELECT ... FOR UPDATE之类的数据库方言锁,优先用 MyBatis-Plus 条件更新保证 MySQL、PostgreSQL、H2 语义一致 - 条件更新成功后,用事件唯一 key 写入或更新支付流水
- 条件更新失败则重新读取订单状态,返回已支付或并发冲突原因
- 写人工复核日志与操作日志
- 分发
CashierPaymentOrderSuccessHandler,余额充值、会员权益、业务订单等场景按scene做幂等后处理 - 发送通知或事件
事件唯一 key 已存在时,直接返回已处理结果,不重复写流水。余额充值这类场景不在 Web3 模块里直接改账,统一走 Payment 成功处理器。
链重组
- 每条链配置独立的
requiredConfirmations - 结算前必须重新查询当前区块高度和交易回执,不能复用缓存
- 结算前必须重新生成计划并校验
reviewFingerprint,指纹包含blockNumber、logIndex和不匹配原因码 - 事件日志
removed=true时,必须阻断结算 - 当前确认数低于要求时,回到
confirming - 已结算后才发现链重组,不自动回滚订单,进入异常复核和补偿流程
生产环境上,BSC、Polygon 这类高吞吐链应设置更保守的确认数;大额订单强制人工复核;公共 RPC 只用于开发调试,生产使用稳定节点服务商或自建节点。
回执探针与两种对账口径
排查一笔 tx 到底对应几条 Transfer,直接看回执:
curl -s "$RPC_URL" -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
"params":["0x"]
}' | jq '.result | {
status,
blockNumber,
logs: [.logs[] | {logIndex, address, to: .topics[2], removed}]
}'
status 为 0x1 表示链上执行成功;logs 中每条 Transfer 有独立的 logIndex;address 是 Token 合约;topics[2] 是收款地址,金额在 data 里需要与订单 amountRaw 直接比对;removed=true 说明该条日志已被重组移除。
两种 SQL 口径的差别:
-- 口径 A:只按 txHash 判重
SELECT 1 FROM cashier_payment_transaction
WHERE chain_name = ? AND transaction_hash = ? AND type = 'web3-settlement';
-- 同一 tx 内第二条 Transfer 会被误判为“已处理”,收款被吞
-- 口径 B:按事件唯一键判重
SELECT 1 FROM cashier_payment_transaction
WHERE chain_name = ? AND transaction_hash = ? AND log_index = ? AND type = 'web3-settlement';
口径 A 在单笔简单转账时表现正常,遇到批量 payout 就失效;口径 B 才能让“事件唯一”这条前置检查真正起作用。字段名以实际表结构为准。
人工、半自动与超时自动通过
三阶段推进:阶段 1 人工确认,只产出 dry-run 计划、复核指纹和复核日志,不改订单,当前已完成;阶段 2 半自动,小额、固定 Token、固定收款地址、事件完全匹配后自动结算,执行器雏形已具备;阶段 3 规则化,按链、Token、金额、商户、风险等级配置自动结算规则,仍在设计态。
超时自动处理相关配置及默认值:
spring.open.money.web3.settlement.review:
auto:
enabled: true
auto-startup: true
task-name: money.web3.payment-settlement-review.auto-handle
fixed-delay: 5m
timeout: 30m
action: auto-approve # 可选 auto-approve / auto-reject
execute-approved: true
limit: 50
failure-notification:
enabled: true
自动重试使用 spring.open.money.web3.settlement.retry.*,周期性扫描可重试候选,写 auto-retry 审计日志后复用同一套显式结算执行器,max-attempts 限制同一订单与复核指纹的重试次数。
两条路径都复用 POST /api/v1/admin/web3/payment-settlement-review-logs/{id}/settle,不维护第二套结算规则。执行入口会重新生成单笔计划并校验 reviewFingerprint,通过后在同一事务内把 pending 订单条件更新为 success,写入类型 web3-settlement 的支付流水,并分发 CashierPaymentOrderSuccessHandler。订单已支付、状态不允许结算、并发更新冲突、指纹变化、计划不再是可结算候选,或同一 outTradeNo + transactionHash + web3-settlement 流水已存在时,返回跳过原因,不写资金状态。自动重试同样不能绕过复核指纹、当前链上计划、状态条件更新和流水幂等。
已结算二次审计:只读,且日志 resolved 不等于资金已修正
定时审计任务由 spring.open.money.web3.settlement.audit.* 驱动,关键项包括:enabled=true、auto-startup=true、task-name=money.web3.payment-settlement.audit、fixed-delay=30m、provider=web3、limit=50、older-than-minutes=60、chain-name=、required-confirmations=(为空时使用订单或链配置)、only-risky=true、risk-notification.enabled=true。
已结算后的链上复查走 POST /api/v1/admin/web3/payment-confirmations/settled-audits。该接口只读复查本地 success 订单,不回滚订单、不修改支付流水、不自动退款,用于识别链重组、RPC 查询失败、确认数不足、交易失败或 ERC-20 Transfer 不匹配等风险。
发现风险后写入 money_web3_payment_settlement_audit_log,后续操作都是运维闭环:
POST /api/v1/admin/web3/payment-settlement-audit-logs/{id}/recheck:重新核验单条风险日志,返回最新审计条目和stillRisky,不改订单、流水或日志状态GET .../compensation-candidates:返回 open + risky 风险日志的只读补偿候选队列POST .../compensation-plan:输出mark-resolved、wait-confirmations、recheck-later、manual-compensation、manual-review等只读建议,响应的riskLevel与chainReorgSuspected用于后台排序和疑似重组提醒PUT .../status-batch:批量把日志标记为 resolved 或 ignored
resolved / ignored 只表示运维处理状态,不代表资金已经修正。真正的链重组补偿或退款仍必须走独立规则和人工复核。
数据模型第一阶段复用已有表:cashier_payment_order、cashier_payment_transaction(含链上事件唯一 key)、cashier_payment_callback_log、money_web3_payment_settlement_review_log、money_web3_payment_settlement_audit_log。
通知内容可以带订单号、链名称、交易 Hash、区块浏览器链接和阻断原因;不能带 RPC URL、私钥、助记词、服务商密钥或任何可直接操作资金的凭据。
对账相关的稳定阻断原因码
原因码保持稳定,便于后台统计和前端展示。与事件级对账直接相关的部分:
| 原因码 | 触发条件 | 对账含义 |
|---|---|---|
chain-id-mismatch | RPC 返回链 ID 与配置不一致 | 查到了错误的链 |
chain-transaction-not-mined | 交易尚未出块 | 还没有回执,无 logIndex |
chain-transaction-failed | 回执 status 非成功 | 交易存在但未执行成功 |
chain-confirmations-insufficient | 确认数低于要求 | 回到 confirming |
transfer-missing | 回执中找不到匹配的 Transfer | 可能对错了事件 |
receiver-address-mismatch | Transfer.to 与订单收款地址不符 | 事件坐标对上了但收款人不符 |
token-contract-mismatch | 事件合约地址与期望 Token 不符 | 同 tx 内其他 Token 的日志 |
amount-mismatch | Transfer.value 与 amountRaw 不等 | 少付、超付或精度问题 |
transfer-removed | 事件已标记 removed=true | 链重组,必须阻断 |
transaction-already-exists | 同订单、txHash、结算类型的流水已存在 | 历史口径可能只按 txHash 判重 |
review-fingerprint-mismatched | 提交指纹与服务端重算结果不一致 | blockNumber 或 logIndex 已变化 |
rpc-query-failed | RPC 查询异常 | 需人工介入,不能当作未支付 |
状态相关的 order-not-found、order-not-settleable、order-update-conflict、local-order-already-paid、provider-not-web3、chain-not-configured 仍然保留,但属于订单与配置层面的判据,与事件匹配无关。
开放问题
进入代码实现前仍需确认:
- 原生币支付如何匹配金额和收款地址
- 是否允许一个订单多笔转账合并支付
- 少付、超付、重复支付如何处理
- 不同链和 Token 的默认确认数策略
- 多商户、多租户下的收款地址归属规则
- 自动结算是否按金额阈值分级
- 自动结算失败是否进入 Queue 或 Scheduler 重试,当前只做 Notification 提醒
这些问题明确之前,规则化全自动结算应保持设计态或人工确认态。