> 适用提交: 6c42f8f | 最后验证: 2026-09-18 | 何时会失效: `payment_transaction.status` 的取值集合、`HuifuPayload` 里返回码的分类分支、或 `talent-card-server/scripts/probes/` 下的探针文件名任一变化时 # 07 排障手册 面向「线上出问题了,怎么查」。六张端到端流程图都在 `01`~`04`,本文件不画图,只给**症状 → 排查路径 → 结论**。 每条结论都带置信度,四档含义见 `README.md`:**【官方原件】** 本地有官方原件可引 /【官方·外部】官方写了但仓库无原件,给 URL /【实测】/【推断】。 凡标【实测】的都是我们自己跑出来的,**汇付侧未书面确认**;本仓库里不存在任何汇付官方回复的留档,所以本文件不出现「已与汇付确认」这种说法。 ## 0. 本文件用到的术语 以根目录 `CONTEXT.md` 为准,这里只列排障时最容易被说错的六个——说错会直接把人带到错误的排查方向。 | 术语 | 一句话 | 别写成 | |---|---|---| | **预支付单** | 通道侧一张**可被付款的凭证**,人才端拉起微信收银台就是凭它 | 支付单、预订单 | | **支付尝试** | 一笔订单向通道发起的一次支付请求;同一时刻最多只有一张活着的预支付单 | 支付流水 | | **关单** | 把未付款的预支付单在通道侧作废;「这张单子还能不能被付款」的**唯一**判定手段 | 取消、关闭订单 | | **关单中** | 叫了关单、通道只回「处理中」,暂时没有结论,必须重试到有结论为止 | 取消失败 | | **悬账** | 通道确认收到、但本地订单已经取消或关闭的钱 | 差错 | | **延迟户 / 交易确认** | 未核销的钱停在通道**延迟户**(不可结算、不可取现);核销后调**交易确认**把钱分给商户基本户 | 中间户 | ## 1. 先看这三件事 **一、回调有没有到?**【实测】 ```sql select provider_request_no, process_status, signature_valid, received_at, processed_at from payment_callback_event where provider = 'HUIFU' order by id desc limit 20; ``` 有行说明到了;`process_status` 必须走到 `PROCESSED` 才算真的推进了状态,`UNMATCHED`/`UNKNOWN_STATUS`/ `PENDING_QUERY` 说明「到了但没落成钱」。**一行都没有** → 看启动日志里 `HuifuCallbackUrlAudit` 念的那五行: 每个端点是 `registered at <地址>` 还是 `is not enabled`。本仓库五个回调地址目前**全部指向一个公网 IP**, 开发机与内网都不可达【实测】,所以本地环境「回调没到」是常态,不是故障。 **二、通道侧的真实状态是什么?**【实测】 回调只是加速器,**通道才是权威**(`docs/adr/0004`)。本地状态与通道不一致时先信通道: 用 `PaymentStateProbe` 比一比(见 §3),不要只看库就下结论。 **三、两者不一致时,谁该被改?**【实测】 `payment_transaction.status` 是这条链路上唯一的状态机,取值与含义固定如下,先确认现象落在哪一格: | 状态 | 含义 | 还能被付款吗 | |---|---|---| | `CREATED` / `CREATE_UNKNOWN` | 已建单、通道答复未定 | 不知道 | | `PREPAY_CREATED` | 渠道侧已有凭证,本地存着支付参数 | **能**(可复用给客户端) | | `CREATE_FAILED` | 通道明确拒绝创建 | 不能 | | `CLOSING` | 已叫关单、通道回「处理中」,**关单可能已生效** | 可能 | | `CANCEL_UNCONFIRMED` | 通道明确说这次关单**没生效**(拒绝关单、或关单请求本身失败) | **能**,是最该复用的一张 | | `CLOSED` | 通道确认已作废 | 不能 | | `SUCCEEDED` | 通道确认收了钱 | — | ## 1.1 五个回调端点各推进什么 回调地址与端点一一对应,排查时先确定「该来的那条通知属于哪个端点」,再去看那条端点的地址配没配。 | 端点(`PaymentCallbackController`) | 配置键(`app.payment.huifu`) | 承接什么通知 | 丢了会怎样 | |---|---|---|---| | `/api/payments/huifu/notify` | `notify-url` | **支付成功**与**退款结果**两条(先按流水号试退款,不中再走支付) | 订单停在「待支付」、退款停在「退款中」 | | `/api/payments/huifu/merchant-business-notify` | `merchant-business-notify-url` | 绑卡(业务开通)结果 | 新增的结算卡状态推不动(**只有回调能推进后加的卡**)【实测】 | | `/api/payments/huifu/merchant-onboarding-notify` | `merchant-onboarding-notify-url` | 商户进件审核结果 | 进件状态停在最后已知值 | | `/api/payments/huifu/merchant-wechat-config-notify` | `merchant-wechat-config-notify-url` | 微信小程序配置结果 | 「人才端绑定」阶段推不动 | | `/api/payments/huifu/merchant-business-open-notify` | `merchant-business-open-notify-url` | 商户业务开通结果 | 「微信支付」阶段推不动,收款能力刷不出来 | 应答格式统一是纯文本 `RECV_ORD_ID_`【实测】;`resp_data` + `sign` 两个表单字段走验签, 验签不过直接 `403`,**不会**写进 `payment_callback_event`。因此「表里有行但 `signature_valid=0`」这种情况不存在—— 表里的行一定是验签通过的。 **通道侧到底注册了哪个地址,本地看不出来。** `HuifuCallbackUrlAudit` 只能报「我们配了什么」,不能报 「通道记住了什么」;这两者必须人工在控台逐条比对。[待确认] ## 1.2 后台只读入口(先看页面能省掉一半 SQL) | 入口 | 看什么 | 注意 | |---|---|---| | 后台「支付对账」页 | `payment_reconciliation_issue` 的 `open` 记录;可标 `resolved`/`ignored`,可点「立即对账」 | 标结掉**只动这一行,不改支付或退款状态**,是审计动作【实测】 | | 后台订单列表「立即对账」 | 跑的是**同一套真实对账逻辑**,不是模拟支付【实测】 | 它按 `pending-after-minutes` 限制范围,急单可能要等够时间才被选中 | | 后台「结算记录」页 | 逐商户问通道结算流水,`failures` 里列出查询失败的商户 | 页面不显示流水时先看 `failures`,那通常是被关停的商户在报错(「客户与产品关系已关闭」),不是页面坏了【实测】 | | 商户端「刷新支付状态」/「继续支付」 | 触发关单查询 + 支付查询 | 两者都会打通道,别当免费按钮连点【推断】 | | 商户端「立即核实」/ 进件页「刷新进度」 | 触发通道能力查询与进度刷新 | 有 5 分钟冷却【实测】;进件未通过时按钮文案是「暂不可核实」 | ## 2. 症状 → 排查路径索引 | 症状(用户/商户的原话) | 先看 | 判定 | 结论与下一步 | |---|---|---|---| | 付了钱但订单还是「待支付」 | `payment_callback_event` 有无该 `req_seq_id`;`payment_transaction.status` | 回调没到(无行),而通道侧已 `trans_stat=S` | 对账快车道会在一分钟内查单补上【实测】;急就在后台订单列表点「立即对账」(跑的是真实对账逻辑)。若通道也说没收钱,是用户没付成功 | | 付了钱、订单却是「已取消」(**悬账**) | `payment_reconciliation_issue` 里 `issue_type='PAYMENT_ORDER_CANCELLED'` | 取消/到期时那张预支付单可能还活着(`CLOSING`/`CANCEL_UNCONFIRMED` 的凭证仍可付),或用户正好在「查单确认没收款、凭证还没关掉」那几秒里付了钱【实测】 | 这是 `StrandedPaymentRefundRetry` 的活:默认 5 分钟一轮自动原路退回付款人【实测】;通道明确拒绝时会另写一条 `STRANDED_REFUND_REJECTED` 并 `log.error`,那才需要人工 | | 点「继续支付」没反应 / 点「取消」一直卡着 | `payment_transaction.status` 是不是 `CLOSING` | 复用集合只有 `PREPAY_CREATED` + `CANCEL_UNCONFIRMED`;`CLOSING` **不在其中**——它可能已作废,交回客户端只会换来微信报错【实测】 | 点「继续支付」或「刷新支付状态」会先跑一次关单查询,有结论就当场落成 `CLOSED` 并给一张新凭证【实测】;仍无结论才要等对账。取消同理:只有「钱收没收下」决定能不能取消,关单结论不挡取消【实测】 | | 微信弹「订单状态异常,支付请求无效」 | 该订单上是否有一张 `close_attempts > 0`、`provider_response_code=23000000` 的流水 | `23000000`/`10000016` = 原订单**已为终态**,不是「现在不让关」;它意味着这张凭证**已经不可能被支付**,绝不能再交回客户端【实测】 | 若状态仍是 `CANCEL_UNCONFIRMED` 且被复用了,属缺陷形态(`.scratch/pending-order-payment-flow/issues/09-terminal-prepay-zombie-order.md`);现实现会落成 `CLOSED` 并建新凭证【实测】 | | 退款一直「退款中」不收敛 | `order_refund.refund_status`;`provider_refund_no` 是否为空 | 退款提交时通道只回「处理中」,退款通知同样到不了本地【实测】 | 读订单/列表前会惰性收敛(提交后 20 秒内不查、一轮最多 2 笔),后台对账一分钟一轮【实测】。查单**不带**退款流水号,所以别用查询结果覆盖 `provider_refund_no` | | 撤销核销后钱没退回**延迟户** / 报「撤销失败」 | `payment_transaction.funds_confirmation_status` | 四种归途:`REVERTED`→回 `pending`;`REVERT_REJECTED`(已结算到银行卡/余额不足)→退回可重试的 `confirmed`;`REVERT_NEEDS_REVIEW`(`23000003`/`23000004` 语义未定)→停在 `reverting`;在途→`reverting`【实测】 | 停在 `reverting` 的现在会被补偿任务扫到并幂等重发【实测】。文案按结论给:`revertRefusalMessage()`,不要只说「撤销失败」 | | 商户说**收款能力**页说能收款、下单却被拒 | 页面判定与下单闸门是否同源 | 硬规定:页面判定与下单闸门必须**同源同文案**(ADR-0002),有测试 `merchantViewReusesTheGateMessage` 钉住【实测】 | 取闸门原话:`MerchantPaymentReadinessService` 会给出被拒的具体原因(如「斗拱子商户状态异常(…),暂时无法收款」),页面应显示同一句;不一致就是缺陷 | | 商户说进件一直停在「审核中」 | `merchant_onboarding_application.status` 与两步配置的 `status` | 通道侧配置失败(两步都 `failed`)时,`submitOnboarding` 见 `status in ('pending','approved')` 直接返回旧记录,银行卡校验又拦「该银行卡已有审核中的进件」【实测】 | 这类进件要收口成 `rejected`,并**同时**收口它绑定的 `merchant_bank_card`(只改进件会让同一张卡继续被拦)【实测】 | | 商户说六项阶段全绿但进件页还说「有待办理项」 | `complete()` 的另两项:`bank`(结算卡核实)与 `merchantNormal`(通道商户状态) | 这两项**不在** `LABELS` 里、`view()` 也不直接返回,只在 `completionChecks` 里;一旦通道把商户停用,申请会掉回 `pending`,页面就成了「标题说待办理、六项全绿」【实测】 | 现在文案会说「进件阶段已全部确认,但……未通过核实」【实测】。**不要把这两项加进 `LABELS`**——那会破坏完成判定(见 `01` 硬规定) | | 后台「结算记录」页一条流水都没有 | 通道返回码;`failures` 数组 | `pageSize` 上限 **50**,超了通道回 `00000003 基础参数校验失败` 且**一条都不返回**(不是截断到 50)【实测】 | 网关内已钉住 `PROVIDER_MAX_PAGE_SIZE = 50`【实测】。若 `failures` 里有被关停商户的「客户与产品关系已关闭」,那是正常降级,不是页面坏了 | | 后台全站变慢/超时(连接池借光) | Hikari 日志里 `leak-detection-threshold` 打出的调用栈 | 池只有 **10** 条连接。历史上两次全站不可用:① 进件回调先 `select … for update` 锁住进件行,再在事务里**同步调汇付 HTTP**;② 启动补列 `ALTER` 无限等元数据锁,该表所有查询排队【实测】 | 看栈里谁在长事务里等外部 HTTP。回调里的通道调用应走 `runAfterCommit`;初始化器等锁上限已压到 5 秒【实测】 | | 测试全红 | 报错原文 | `Connection refused` 或 `Unable to determine Dialect without JDBC metadata` → 测试库那个 8.0.36 在 **WSL 的 docker** 里,VM 空闲自杀,容器跟着停【实测】 | 见 `docs/agents/testing.md`:起容器**不够**,要挂一个长命进程。只有 3 项是已知基线失败,其余按回归查 | ## 3. 探针工具 探针在 `talent-card-server/scripts/probes/`,**只读**,安全约束见 `scripts/probes/README.md`: **只读、不改任何状态、输出脱敏(不打印私钥/密钥/完整卡号)、生产环境默认拒绝运行任何写操作。** 运行方式沿用既有先例 `talent-card-server/docs/audits/2026-09-10/run-probe.ps1`,三步,与探针类名无关: ```powershell cd talent-card-server # 1) 编译并把运行时+测试 classpath 落到文件 mvn -q -DskipTests compile dependency:build-classpath '-Dmdep.outputFile=target/probe/classpath.txt' '-Dmdep.includeScope=test' # 2) 编译探针(classpath = target/classes + 上面那份 classpath.txt) javac -encoding UTF-8 -cp "target/classes;$cp" -d target/probe scripts/probes/<探针>.java # 3) 运行(注意输出目录也进 classpath) java -cp "target/probe;target/classes;$cp" <探针> ``` | 探针 | 回答什么问题 | 什么时候用它 | |---|---|---| | `PaymentStateProbe` | 某笔支付在**本地**与**通道**的真实状态分别是什么、差在哪 | 症状是「付了钱订单还是待支付」或「状态对不上」时的第一站;它把两边摊开,避免只看库 | | `MerchantCapabilityProbe` | 某商户的**本地能力快照**与通道 `basicdata` 的真实值是否一致 | 「页面说能收款、下单被拒」「商户被控台停用但我们还显示正常」;也是排查 `20002 数据权限认证失败` 时的对照工具 | | `OrderFundBoundaryProbe` | 某订单的**资金边界**、`funds_confirmation_status` 与相关资金问题记录 | 撤销核销报「撤销失败」、钱冻在延迟户、怀疑一单多退时用;一次把边界与问题记录都打出来 | | `SettlementRecordProbe` | 直查通道**结算记录**(含 `pageSize=50` 的教训) | 后台「结算记录」页空、或怀疑某天没结算到银行卡时用;它绕过页面直接问通道 | | `CallbackUrlAuditProbe` | 单跑一次的**五个回调地址自检** | `HuifuCallbackUrlAudit` 现在只在启动时跑;改了地址或怀疑「回调未启用」而不想重启时用 | **不要用探针做的**:任何写通道、改本地状态、重发业务请求的动作。探针的职责是把「通道怎么说、本地怎么记」 摊开,判定仍然由 §2 的路径和 `08` 的验收清单给出。探针是单文件 `main` 程序,参数形式以它自身的用法输出为准; 它在 `talent-card-server` 模块根目录下运行,读的是同一份 `application.yml`,所以**换环境查之前先确认你连着哪个库**。 **选哪个探针**:状态对不上先 `PaymentStateProbe`;商户侧能力对不上用 `MerchantCapabilityProbe`; 钱的位置不确定(延迟户/悬账/重复退款)用 `OrderFundBoundaryProbe`;只怀疑结算用 `SettlementRecordProbe`; 怀疑回调根本没启用用 `CallbackUrlAuditProbe`。 ## 4. 常见误判 排障时间最常浪费在「看起来像 A,其实是 B」。以下每条都是本仓库真实踩过的。 1. **「回调丢了」看起来像通道的问题,其实是我们的部署没有公网。**【实测】 五个回调地址全指向一个公网 IP,平台没有公网服务器;开发机与内网都不可达,所以本地「回调没到」是必然。 更早还有一层:`merchant-business-notify-url` 这个键原本根本不存在,靠 `notify-url` 推导出一个值—— 「通道侧到底注册了哪个地址」取决于另一个配置。现在它显式写在 `application.yml` 里,缺一个都能一眼看见【实测】。 2. **「通道流水号丢了」不是通道没给,是查询回执与通知的字段名不同。**【实测】 支付查询回执里是 **`org_hf_seq_id`**,`hf_seq_id` **只出现在异步通知里**。取错会**静默写成 NULL**, 退款与排查就此失去唯一凭证。注意这与官方支付查询响应表写的不一致(官方表写 `hf_seq_id`)—— 我们按实测走,但**官方表与此冲突这件事本身要留着**,新增用途前先取证。 3. **「退款查不到」不是没退,是退款查询回执不带退款流水号。**【实测】 通道退款流水号只在**发起退款的那一次回执**里;退款查询回执不带它。旧实现无条件覆盖,把唯一凭证擦成空串 (开发库 refund 43/44 的 `provider_refund_no` 都是空)。现在用 `coalesce(nullif(?, ''), …)` 保留。 官方退款查询响应表列有 `org_hf_seq_id`「退款全局流水号」,与我们的实测不一致——同样按实测走,不要断言官方错。 4. **「关单失败」可能是 `90000000 交易失败,请重试`——它表示这次关单没生效,凭证还活着。**【实测】 这与 `CLOSING`(关单可能**已**生效)语义**完全相反**,必须分开建模。同理别把「其余拒绝」都当成一类: `23000000`/`10000016` 说的是「原订单已为终态」,即这张凭证已经不可能被支付;把它们并入「其余拒绝」, 就会把一张**死凭证**记成 `CANCEL_UNCONFIRMED`(=「可能还能付」)继续交给客户端。 5. **「`trans_stat=F` 所以失败了」——`23000003` 会同时带 `F`,但在交易确认路径上是幂等成功。**【实测】 通道在「已无未确认金额」时同时给 `trans_stat=F`。规则是**先判业务码,再判交易状态**; 旧实现先看状态,把幂等成功判成失败,于是补偿任务每隔几分钟无意义地打一次通道、永远不自愈。 注意同一个码在**交易确认退款**(回退)路径上语义未定,一律停在 `reverting` 交人工【推断】。 6. **「进程重启后列就有了」——初始化器补列是 best-effort,重启过不代表有新列。**【实测】 `MerchantOnboardingSchemaInitializer` 的 `ALTER` 等 DDL 元数据锁上限只有 5 秒,等不到就记一条 error 放行 (宁可少一列,也不能让启动被 DDL 挂住)。缺列时的表象经常**不像缺列**:`legal_mobile` 缺失会让实名被拦成 「发起实名还缺:法人手机号」,看起来像商户没填。启动日志里搜 `Merchant onboarding schema: could not add`。 7. **「进件卡住是商户资料不全」——常常是我们自己的顺序错了。**【实测】 「微信小程序配置」必须排在「商户业务开通」**之后**(微信子商户号是业务开通创建的);顺序反了必被驳 「根据渠道号或微信子商户号未查到入驻记录」,而且配置一失败就 `return`,业务开通永远轮不到(申请 452 有库内证据)。 另一支:`90000000` 是「受理/审核中」,**不是失败**,旧网关漏了它,会把一次成功受理记成 `failed`。 8. **「撤销核销报错就是钱退不回来」——四种归途对应四种话术。**【实测】 已结算到银行卡/基本户余额不足 → 通道明确拒绝,退回可重试的 `confirmed`,窗口实际上**止于下一次自动结算**; 通道回的处理中 → 停在 `reverting`,稍后重试即可;`23000003`/`23000004` → 语义未定,**必须人工核对**, 让商户反复重试没有意义【推断】。把这三类统一说成「撤销失败」,商户只会一直点。 9. **「`20002 数据权限认证失败` 是进件归属错了」——与商户层级无关。**【实测】 曾以为是归属错,改用 `head_office_flag=0` + `head_huifu_id` 把商户建成平台下级,**实测无效**: 20002 取决于**签名身份本身**有没有那些商户的数据权限,head 路径还额外索要 F725/F726/F727 材料。 把凭据轮换成目标商户的**直属上级渠道**之后 20002 才解除。同一身份操作自己的数据全部 `00000000`【实测】。 不要为了消掉 20002 去改动商户层级——那只会平白增加进件失败面。 10. **「结算记录页那四个查询条件没用」不是通道不支持,是前端根本没发出去。**【实测】 切到结算记录时只发了日期与条数,商家/联系电话/商家类型/状态四个条件从未上送;日期栏标签写 「申请时间」,实际筛的是**结算日期**。看到「条件无效」先看请求参数,别怀疑通道。 11. **「快速切标签时表格内容串了」是响应覆盖,不是数据错了。**【实测】 `load()` 写回表格前没有身份校验,两次请求同时在飞时后到者覆盖先到者——而结算记录要逐商户问通道, 正是最慢的一次请求,于是出现「表头是进件的列、内容是结算记录、总数也变成结算的条数」。 这类「无请求序号」的写法在别的后台页面也可能有。 12. **「对账台报唯一键冲突」不是数据坏了,是唯一键里含了状态。**【实测】 `payment_reconciliation_issue` 的唯一键是 `(issue_type, provider, business_no, status)`,同一笔业务被 「结掉又开出」时,结掉那一步会撞上已存在的 `resolved` 行。看到这个报错先按业务号把该表的历史行都拉出来, 而不是直接删行。 ## 5. 升级人工 **不要再自己试**的情形(继续试的代价是钱或数据): - **资金已经错了,且无法程序化收敛**:例如 `funds_confirmation_status` 停在 `reverting` 且缺 `funds_revert_no`/ `funds_confirm_no` 等定位键(重发不了);`STRANDED_REFUND_REJECTED`(悬账退不动); `funds_confirmation_status='confirming'` 孤儿(进程中断留下的,钱冻在延迟户)。【实测】 - **通道返回语义未定**:`23000003`/`23000004` 出现在退款或交易确认退款路径上时【推断】。 这两个码在交易确认路径上已验为幂等成功,在反方向没有回执依据;**判成功等于放行可能没动的钱,判失败等于重复出款**。 - **需要汇付侧改配置或给依据**:控台侧的回调地址注册、费率规则号(CID)、商户被控台停用、 凭据轮换窗口。凡属「官方这么规定的」而没有本地原件的,**只能标【官方·外部】并附官方 URL,绝不能写成「已与汇付确认」**。 升级时要一次给全的材料(缺一项就要多一轮往返): 1. **环境**:哪个环境、哪个库、`app.payment.huifu.environment` 的值(`test`/`prod`); 2. **标识**:订单号 `benefit_order.order_no`、支付流水号 `payment_transaction.payment_no`、退款号 `order_refund.refund_no`, 通道侧流水号(回执里的 `org_hf_seq_id` 或通知里的 `hf_seq_id`)——**脱敏后再给**; 3. **时间**:现象发生时刻(精确到分)、最近一次对账时间(`last_reconciled_at`)、 最后一次成功回调时间(`payment_callback_event.received_at`); 4. **报文原文**:通道回执/通知的原文(**不要**只给截图或转述),以及 `provider_response_code`/`provider_response_message`; 5. **影响面**:涉及几笔订单、金额合计、是否还有同一形态的其他单(给查询条件,不要只给一条); 6. **已做动作**:跑过哪些探针、点过哪些后台入口(「立即对账」「刷新支付状态」「立即核实」)、改过什么配置。 **升级时不要顺手做的事**:直接改库里 `payment_transaction.status` 或 `funds_confirmation_status`、 手工把订单置为已支付或已取消、重发没有幂等键的通道请求。这些动作会破坏 「任何成功资金事实都能定位本地凭证」这条不变量,而悬账判定与自动退款都靠它。