适用提交: 6c42f8f | 最后验证: 2026-09-18 | 何时会失效: 支付尝试状态集合、可复用集合、资金确认状态或退款额度规则被改动时
覆盖:下单、回调与查单、关单与取消、延迟入账与交易确认、退款与资金回退。汇付侧的协议细节在 05,我自己方代码的坑在 06(本文件只挑最易再犯的 5 条)。
本文件术语(权威定义见根目录 CONTEXT.md):**预支付单**(通道侧一张可付款的凭证,不是本地订单)、**支付尝试**(一笔订单向通道发起的一次支付请求)、**关单**(把预支付单在通道侧作废)、**支付结果不确定**(问不出「钱到底收没收到」,描述的是**一次支付尝试**而非整笔订单)、**关单中**、**悬账**(通道确认收到但本地订单已取消的钱)、**延迟户**、**交易确认**、**基本户**。
一笔订单可以有很多次支付尝试,但**同一时刻最多只有一张活着的预支付单**。payment_transaction.status 的取值全集只有这 8 个(talent-card-server/src/main 里 grep 得到,权威映射是 HuifuPaymentService.statusText()):
stateDiagram-v2
[*] --> CREATED: 建流水
CREATED --> PREPAY_CREATED: 通道受理,本地存下支付参数
CREATED --> CREATE_FAILED: 通道明确拒受理
CREATED --> CREATE_UNKNOWN: 请求结果未知
PREPAY_CREATED --> SUCCEEDED: 回调或查单确认收到钱
PREPAY_CREATED --> CLOSING: 发起关单
CLOSING --> CLOSED: 关单查询确认已关闭
CLOSING --> CANCEL_UNCONFIRMED: 通道明确说这次关单没生效
CANCEL_UNCONFIRMED --> SUCCEEDED: 复用它支付并成功
PREPAY_CREATED --> SUCCEEDED: 复用它支付并成功
note right of CLOSING
CLOSING 不可复用
(关单可能已生效,凭证可能已死)
结论只能由关单查询给出
end note
note right of CANCEL_UNCONFIRMED
可复用(凭证还活着)
通道拒关单,或关单请求本身失败
end note
图的文字兜底:创建流水后,通道受理就变成有一张可付款凭证的 PREPAY_CREATED。用户要关掉它就走 CLOSING——但关单是异步的,所以 CLOSING 是个**悬而未决**的状态,只能靠**关单查询**给出结论:确认关掉了是 CLOSED,通道明确说没关掉是 CANCEL_UNCONFIRMED。后者听起来像「取消没成功」,实际上是**凭证还活着**,所以它是可复用的。
| 状态 | 对外 paymentStatus |
含义 |
|---|---|---|
SUCCEEDED |
PAID |
钱已确认收到 |
CLOSED |
CANCELLED |
凭证已在通道侧作废 |
CANCEL_UNCONFIRMED |
CANCELLED |
用户意图已生效,但**凭证在通道侧可能还活着** |
CREATE_FAILED |
PAYMENT_CREATE_FAILED |
通道明确拒受理 |
CREATED、PREPAY_CREATED |
PAYMENT_PENDING |
待支付 |
CLOSING、CREATE_UNKNOWN |
PAYMENT_PROCESSING |
还在确认(客户端渲染成**可等待**文案,不是报错) |
| 无流水 | NOT_STARTED |
— |
01 §4 第 2 条)只有 PREPAY_CREATED 和 CANCEL_UNCONFIRMED 两张可复用,且还要同时满足另外三个条件:订单仍待支付且未过期、支付参数还在本地、**流水金额与订单当前实付一致**。
金额这个条件看着多余,其实不是:**预支付单是按金额建的**,改了优惠券之后再拿旧单去收款会收错钱。流水上没记 price_version,所以金额相等是这里能拿到的最强判据(HuifuPaymentService.reusablePrepay() 的 javadoc 里写明了这一点)。
CLOSING 不在可复用之列:那次关单**可能**已经生效。交回去只会换来微信收银台一句「订单状态异常,支付请求无效」。它的结论由 PaymentReconciliationService.concludeUndecidedCloses() 给出。
入口:business/portal/PendingOrderPortalController(人才端)→ HuifuPaymentService。
订单在创建时就固定收款子商户(benefit_order.collect_onboarding_id / collect_store_id / collect_huifu_id)。这是防「支付请求漂移到别家门店」的——没有这个固定,「用 A 门店下单、钱付给 B 门店」是可能的。
已知缺口:防重只按 idempotency_key 查已有流水,**不按订单**阻止另一个键创建有效流水。所以「一次订单 + 两个幂等键」能造出**两张都能被支付的预支付单**,而两笔都成功时**没有多收款退回流程**(审计 §9 已复现)。前端固定的键是「订单号 + 价格版本」,但这依赖前端不换键。
两个入口,一份实现。
通道回调 PaymentCallbackController → HuifuPaymentCallbackService(先验签 → 解析 → 去重)
↘
PaymentSettlementService ★ 唯一幂等结算入口
↗
对账兜底 PaymentReconciliationScheduler(默认 1 分钟一轮)→ PaymentReconciliationService
PaymentSettlementService 在改本地状态**之前**会比对:请求号、商户号、金额、订单归属。对不上的落 SettlementOutcome 的对应分支(UNMATCHED / MERCHANT_MISMATCH / MERCHANT_UNVERIFIED / AMOUNT_MISMATCH / COLLECTOR_UNKNOWN / CLOSED_LATE_SUCCESS …)并写差错台账,**不改状态**。
这条设计来自一次真实缺陷:**回调原先只查流水 ID、订单 ID、状态,不取金额比对**,于是一个 0.01 元的成功回调可以把 100 元订单改成已支付。现在两个入口共用同一套校验。
回调一定会丢。 本地与无公网部署根本收不到(回调地址指向公网 IP),所以对账不是「优化」而是**唯一的兜底**。这也是硬规定第 5 条(ADR-0004)的来源。
关单是异步的,而且关单查询是**另一个接口**(HuifuPaymentCloseQueryCommand)。只发关单不问结论,用户会一直卡着——实测有人等了 72 秒。
PaymentReconciliationService 里的关单逻辑要点:
| 项 | 现状 | 为什么 |
|---|---|---|
| 关单重试间隔 | 通道**不允许关闭一分钟以内**的订单,所以最短间隔被通道钉在 1 分钟 | 通道约束 |
| 扫描周期 | 由 fixed-delay-ms 决定(默认 300000 = 5 分钟) |
⚠️ CLOSE_RETRY_MINUTES=1 只是「距上次处理满一分钟」的**资格窗口**,不是扫描周期——曾经把这两个混为一谈,于是「每分钟重试一次、最多三次」实际变成「每五分钟一次、最多十五分钟」 |
| 终态码 | TERMINAL_CODES = {"23000000", "10000016"},语义是「原订单**已为终态**」(已完成或已关单) |
不能并入「其余拒绝」,否则一张已经不可能被支付的单子会被当成「还能付」继续交回客户端 |
| 关单请求本身失败 | 90000000 交易失败,请重试 表示这次关单**没生效** |
与 CLOSING 语义**完全相反**,必须分开建模 |
取消:按用户意图**立刻生效**,不再等关单结论(用户没付过钱,而通道对刚创建的微信交易最常答「关单处理中」——实测三次取消全部如此)。代价写在 ADR-0003 的修订里:如果用户恰好在「查单之后、凭证被关掉之前」那几秒完成支付,钱会被收下然后自动退回,**手续费大概率不退**。
stateDiagram-v2
[*] --> NULL: 非延迟入账(支付即核销)
[*] --> pending: 延迟入账,钱进延迟户
pending --> confirmed: 核销 → 交易确认成功,钱进商户基本户
confirmed --> reverting: 撤销核销 → 交易确认退款在途
reverting --> pending: 回退成功,钱回延迟户
reverting --> confirmed: 回退被拒(已结算到银行卡/余额不足),退回可重试
note right of reverting
钱在哪一边未定 → 交人工
此时不允许撤销核销
end note
payment_transaction.funds_confirmation_status 取值:NULL 未走延迟入账 / pending 钱在延迟户 / confirmed 已分账到商户基本户 / reverting(**这是撤销核销期间的占位值**,没有独立的 funds_revert_status 列;回退细节记在 funds_revert_*)。
本链路最反直觉的一条:延迟入账下「**订单已核销**」≠「**商户已拿到钱**」。钱停在通道「待确认」,必须显式调【交易确认】才按分账串分配给商户;未核销的钱官方定义是「不可结算、不可取现」。
撤销核销必须走反方向:调用通道的【交易确认退款】把已确认的金额退回延迟户。只翻库不碰钱 → 订单回到「待核销」而钱留在商户手里,**等 D1 结算到银行卡后就再也追不回来**。所以撤销窗口实际上**止于下一次自动结算**。
回退结果的四种归途(HuifuPayload / HuifuDelayAccountGateway):
| 通道答复 | 我们的处理 |
|---|---|
| 成功 | → pending |
| 明确拒绝(已结算到银行卡 / 余额不足) | 退回可重试的 confirmed |
语义未定(23000003 / 23000004) |
停在 reverting 交人工 |
| 通道还在处理 | 在途 |
最后一行是刻意的保守:23000003 在交易确认路径上**已验证**是幂等成功,但在**退款/交易确认退款**路径上**语义未定**。判成功等于放行可能没动的钱,判失败等于让本地以为钱没动、于是**再动一次(重复出款)**——两个方向的错都很贵,所以停在中间态。
入口 HuifuRefundService,硬门在 OrderFundBoundaryService.requireRefundable()。
额度规则:按「已成功金额 + 处理中金额 < 实付」控制。在途金额也要算进去,否则同一笔钱会被退两次。曾经错写成「存在退款中或已退款就阻止新退款」,后果是**实付 100 退了 40 之后剩下的 60 永远退不了**。
读路径必须收敛:退款提交时通道只回「处理中」,退款通知和支付通知一样会丢。所以读订单与列表**之前**要先向通道收敛一次。这里有个事务细节:稳定收敛**必须是调用方事务的第一条读语句**——MySQL 的一致性快照就在那条语句上定下,放在后面读到的还是旧数据。
悬账有自动出口:StrandedPaymentRefundRetry 每 5 分钟一轮,把「通道确认收到、但订单已取消」的钱自动原路退回。这类钱在旧实现下只在账目上留一条问题记录等人工,而正常退款接口要求 order_status=1,**根本没有程序化出口**。
| # | 现象 | 根因 | 档 |
|---|---|---|---|
| 1 | 用户没付过钱但订单被取消,钱却在通道被收下,**连退款入口都没有** | 取消/超时只改本地,**从不调通道关单**;用户手上那张预支付单照样能付进来 | 【实测】 |
| 2 | 「关不掉的预支付单」所在订单被自动取消,而那张单子**仍然可付** | 到期清理的排除表漏了 CANCEL_UNCONFIRMED;钱进来后记 PAYMENT_ORDER_CANCELLED,而正常退款要求 order_status=1,这笔钱没有程序化出口 |
【实测】 |
| 3 | 一笔订单存在两张都能被支付的预支付单,且两笔都成功时**没有多收款退回流程** | 防重只按 idempotency_key 查,不按订单阻止另一个键创建有效流水 |
【实测】 |
| 4 | 撤销核销只翻库不碰钱,等 D1 结算到银行卡后**再也追不回来** | 核销时钱已从延迟户划给商户,撤销必须调【交易确认退款】 | 【实测】 |
| 5 | 补偿任务每隔几分钟无意义地打一次通道,永远不自愈 | 通道对「已无未确认金额」返回 23000003 时**同时给 trans_stat=F**;先看交易状态再看返回码,必然把幂等成功判成失败 |
【实测】 |
(第 1 条对应当前代码已修复,但它是这段链路上最贵的一次缺陷,留着当回归检查项。)
| 位置 | 文件 | 触发 |
|---|---|---|
| 人才端待支付页 | rencaika/pages/pending-payment/ |
POST /api/app/pending-orders/{orderNo}/payment(下单/复用)、/cancel |
| 人才端轮询 | 同上 | GET /api/app/pending-orders/{orderNo}/payment 查状态;退避到约 45 秒 + 手动「刷新支付状态」 |
| 后台订单列表/详情 | talent-card-admin/src/views/OrderView.vue、OrderDetailView.vue |
核销、退款、「立即对账」 |
| 后台退款记录 | talent-card-admin/src/views/RefundRecordView.vue |
退款列表、「刷新退款状态」 |
| 后台 API | talent-card-admin/src/api/admin.ts |
refundOrder、refreshRefundStatus、runPaymentReconciliation |
⚠️ 改这两端的前端只能靠真机验证(本机没有 HBuilderX / uni CLI,没有可编译的反馈回路)。第 46 类缺陷——客户端 loadOrder ↔ checkPaymentStatus 互相触发形成**每秒闪一次**的无限循环——静态读代码看不出来。详见 01 §2.4。