日期:2026-09-21
状态:待实现
上游文档:https://doc.leshouying.cn/web/#/611206641/196236533
接口摘录:D:\project2\leshouying-api-docs.md(30 节,含完整目录树与逐页参数)
把乐收赢接入为上游支付通道,并包装成下游支付 API 提供给我们平台的租户使用。三层结构:
乐收赢(上游 PSP)
↕ MD5 签名(机构密钥)· /v3/* 与 /api/* 两套报文
我们平台(java/,Spring Boot 3.2.5)
↕ ApiKey + HMAC-SHA256 · 复用现有机制,不改协议
我们的用户(租户 / 企业)
| 场景 | 上游接口 | 触发方式 |
|---|---|---|
| 付款码支付(被扫 B扫C) | v3/pay |
收银员扫用户付款码 |
| 聚合码支付(主扫 C扫B) | v3/qrpay |
PC 网页展示二维码,用户手机扫 |
| H5 支付 | v3/h5pay |
移动端浏览器唤起微信/支付宝 |
| 交易查询 | v3/queryorder |
|
| 撤销(被扫) | v3/cancelorder |
|
| 关单(主扫) | v3/qrclose |
|
| 退款 | v3/refundorder |
|
| 退款查询 | v3/queryrefund |
|
| 回调接收 | 交易回调 + 退款回调 | 免登录入口 + 强制验签 |
外加:下游 7 个 OpenAPI 端点、4 张表的持久化(见 §4.2)、2 个调度任务、前端订单/退款/商户映射管理页。
| 项 | 原因 |
|---|---|
JSAPI 支付 v3/jspay |
微信内置浏览器场景,需 open_id + 服务商公众号授权,链路长一个量级。用户已确认本期不做 |
| 小程序支付 / 小程序数币 | 无场景 |
| 云订单支付 | 无场景 |
总分店模式(*【总分店模式】 三个接口) |
单机构单商户模式,无总分公司场景 |
分账 share_type / share_detail |
无场景 |
| 营销 / 电子发票 / IOT / 会员 / 对账接口 | 不属于本次范围 |
商户进件全套(/api/merchant/*、/api/shop/*、省市区、行业类目等) |
子项目 B,见 §13 |
龙支付(pay_type=900)、云闪付(600,仅 H5 支持) |
无场景 |
这四条是设计的前提,都在上游文档里有明确出处:
qrpay 不返回 PAY_IN_PROCESS§11 聚合码返回参数只有 PAY_SUCCESS / PAY_FAIL。主扫下单是同步确定的,不需要同步轮询。
v3/qrclose,入参是 code_url 而非 trade_no§29 / [page 196236547]。「聚合码支付」场景介绍 [page 196236545] 的 API 列表里
只有 qrpay / queryorder / refundorder,没有 closeorder。
§6 通用 closeorder 正文写的是"二维码支付、JSAPI支付可用"。
→ 走 qrpay 下单的订单,关单必须用 qrclose + code_url。
h5pay 会返回 PAY_IN_PROCESS,且无已知关单接口§27。H5 目录下只有「H5支付下单」一页;§6 closeorder 未提及 H5。
→ H5 订单的关单方式未定义,列入待确认(§14),v1 用本地状态收敛兜底(§6.4)。
§5:撤销对已支付的单会把资金退还给用户,但返回只有 PAY_SUCCESS / PAY_FAIL。
→ 直接调 cancelorder 会导致本地状态记错。设计用"撤销前先查"绕开(§6.3)。
java/src/main/java/com/payment/platform/module/payment/leshouying/
沿用 HAR 模块已建立的布局(git stash show -u stash@{1} 可见其分层):
leshouying/
client/
LeshouyingClient.java HTTP 调用:签名、POST JSON、报文路由、异常包装
LeshouyingSigner.java MD5 签名生成 + 入站验签(无任何跳过路径)
LeshouyingResponseParser.java 两套报文信封解析(v3/* 与 api/*)
LeshouyingApiException.java 上游业务异常
dto/ 上游请求/响应对象(按接口一进一出)
config/
LeshouyingProperties.java inst_no / 机构密钥 / baseUrl / 各窗口参数
controller/
LeshouyingOrderController.java 管理端订单查询/详情
LeshouyingRefundController.java 管理端退款查询/详情
LeshouyingMerchantController.java 管理端商户映射 CRUD
LeshouyingNotifyController.java 乐收赢回调入口(免登录)
open/
LeshouyingOpenController.java 下游 7 个 OpenAPI 端点
dto/ 下游请求/响应对象
entity/ LsyOrderEntity / LsyRefundEntity / LsyMerchantEntity / LsyNotifyLogEntity
enums/ LsyOrderStatus / LsyRefundStatus / LsyPayScene / LsyPayType / LsyNotifyStatus
mapper/ 4 个 Mapper
scheduler/
LsyOrderPollScheduler.java 被扫密轮询 + 超时撤销
LsyReconcileScheduler.java 主扫/H5 兜底对账 + 关单 + 下游回调重试
service/
LsyOrderService.java 下单(被扫/主扫/H5)+ 状态机
LsyQueryService.java 交易查询
LsyCancelService.java 撤销/关单(按场景路由)
LsyRefundService.java 退款 + 退款查询
LsyNotifyService.java 回调验签 + 幂等 + 状态回写
LsyCallbackService.java 回调我们的下游(含持久化重试)
为什么拆这么细:LsyOrderService 若把三种下单 + 状态机 + 轮询判定全塞进去会超千行。
下单入口按场景分方法但共用一个状态机;查询/撤销/退款各自独立,便于单独测试。
inst_no + 机构密钥是平台级的——我们是一个机构,各企业在它下面开商户。
因此放 LeshouyingProperties(application.yml),沿用 HAR HarProperties 的做法,不入库,避免机构密钥明文落库。
只有 mch_no / shop_no 是每企业一份,入表。
pay_lsy_merchant — 企业 ↔ 乐收赢商户映射| 字段 | 类型 | 说明 |
|---|---|---|
| id / tenant_id / enterprise_id | (tenant_id, enterprise_id) 唯一 |
|
| mch_no | varchar(32) | 乐收赢商户号,必填 |
| shop_no | varchar(32) | 门店号,可空 |
| merchant_name | varchar(128) | 商户名称(冗余,便于列表展示) |
| review_status | varchar(32) | 进件审核状态,A 阶段手工填 APPROVED |
| remark | varchar(255) | |
| 审计字段 | 沿用 PaymentTenantBaseEntity |
A 阶段此表由管理端手工维护(测试用
Y52211090004)。 B 阶段进件成功后自动写入,review_status接乐收赢的审核通知。
pay_lsy_order — 支付订单| 分组 | 字段 |
|---|---|
| 归属 | id、tenant_id、enterprise_id、mch_no、shop_no |
| 下游 | out_trade_no(唯一)、api_key_id、notify_url(下单时快照)、attach |
| 场景 | pay_scene(BARCODE/QRCODE/H5)、pay_type(000/300/400) |
| 金额 | total_amount、receipt_amount、discount_amount、refunded_amount(均 bigint,单位分) |
| 上游 | pay_trace_no(唯一)、pay_time、trade_no、td_trade_no、qd_trade_no、user_id |
| 主扫/H5 | code_url(text)、code_url_expire_time |
| 状态 | status、upstream_result_code、upstream_result_msg |
| 轮询 | query_count、next_query_time、last_query_time |
| 回调 | notify_status、notify_count、last_notify_time |
| 收尾 | finish_time、order_body、审计字段 |
auth_code(付款码)不落库。它是一次性支付凭证,等同卡号,存了也不能复用; 对账靠trade_no/pay_trace_no。日志里整段替换为***(不是「前2后2」—— 见计划 Task 2:留下任何一位数字都是在把凭证的一部分写进 ERROR 日志文件, 而遮蔽强度不因保留 2 位而提升到任何有用的程度)。
pay_lsy_refund — 退款单id、tenant_id、order_id、out_trade_no、out_refund_no(唯一)、
refund_trace_no(唯一)、refund_no、qd_refund_no、refund_fee、refund_settle(bigint 分)、
status、upstream_result_code、notify_status、notify_count、finish_time、审计字段。
⚠️ 字段语义陷阱(§9):退款接口的
pay_time是退款请求时间, 原支付时间是pay_trace_time。命名极易搞反,实现时两者都存,注释标明。
pay_lsy_notify_log — 回调审计id、tenant_id、trade_type(0退款/1交易/2云订单关单)、trade_no、pay_trace_no、
raw_body(text)、verify_status、process_status、error_msg、审计字段。
追加写,不做唯一约束——幂等由状态机的条件更新保证(§5.2),日志保留完整重试轨迹便于对账。
java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql
⚠️ Flyway 顺序陷阱:当前最新已应用版本是
V1.7。 HAR 模块的V1.8__create_pay_har_tables.sql还在stash@{1}里未执行。 乐收赢必须占用V1.8;HAR 恢复时需将其脚本重命名为V1.9, 否则V1.8排在已应用的V1.9之后,Flyway 默认outOfOrder=false会直接报错。
菜单脚本:java/src/main/resources/db/lsy-menu.sql(对齐 batch-menu.sql 的写法)。
CREATED ──┬─→ PAYING ──┬─→ SUCCESS ──┬─→ PARTIAL_REFUNDED ──→ REFUNDED
│ ├─→ FAIL └───────────────────────→ REFUNDED
├─→ SUCCESS ├─→ CANCELED ─────→ REFUNDED (竞态:撤销瞬间用户付款成功)
└─→ FAIL ├─→ CLOSED ───────→ REFUNDED (竞态:同上)
└─→ EXPIRED
| 状态 | 含义 | 终态性 |
|---|---|---|
CREATED |
已建单,未调上游 | 非终态 |
PAYING |
上游返回 PAY_IN_PROCESS,待确认 |
非终态 |
SUCCESS |
支付成功 | 支付终态,可转退款态 |
FAIL |
支付失败 | 终态 |
CANCELED |
被扫撤销成功 | 支付终态,竞态下可转 REFUNDED |
CLOSED |
主扫关单成功 | 支付终态,竞态下可转 REFUNDED |
EXPIRED |
超时未支付,本地收敛 | 终态 |
PARTIAL_REFUNDED |
部分退款 | 非终态 |
REFUNDED |
全额退款 | 终态 |
"支付终态"的定义:不会再回到
PAYING/ 收到支付成功。但SUCCESS/CANCELED/CLOSED都允许因退款流转到PARTIAL_REFUNDED/REFUNDED——CANCELED与CLOSED的这个流转 只在竞态下发生(撤销/关单的瞬间用户完成了支付,上游随即退款)。 因此 §5.2 的非终态集合 =CREATED/PAYING/PARTIAL_REFUNDED, 而退款回写单独放行SUCCESS/CANCELED/CLOSED。
退款单状态:CREATED → REFUNDING → SUCCESS / FAIL。
所有状态流转一律走条件更新,且按流转方向使用不同的放行集合:
| 流转 | 放行来源状态 | SQL 条件 |
|---|---|---|
| 支付结果回写 | CREATED / PAYING |
... WHERE id = ? AND status IN ('CREATED','PAYING') |
| 退款回写 | SUCCESS / CANCELED / CLOSED / PARTIAL_REFUNDED |
... WHERE id = ? AND status IN ('SUCCESS','CANCELED','CLOSED','PARTIAL_REFUNDED') |
两个集合互不重叠:支付回调永不覆盖已退款订单,退款回调也永不把非支付单改成退款态。
这样重复通知天然成为 no-op,且不误伤"更正型"通知——例如撤销后收到退款回调需要
CANCELED → REFUNDED,若用唯一约束硬挡或共用同一个来源集合就会丢数据。
下游 POST /payment/openapi/leshouying/barcode/pay
→ ApiKey + HMAC 验签(现有 filter)
→ 幂等:out_trade_no 已存在则直接返回原结果
→ 查 pay_lsy_merchant 取 mch_no / shop_no
→ 落单 status=CREATED
→ 调 v3/pay(pay_type 默认 000 自动识别,auth_code 透传不落库)
├ PAY_SUCCESS → SUCCESS,立即返回下游
├ PAY_FAIL → FAIL,立即返回下游
└ PAY_IN_PROCESS → PAYING
├ 同步轮询 3 次 × 2 秒(用户就站在收银台前,要有即时反馈)
│ └ 命中终态 → 更新并返回下游
└ 仍支付中 → 返回下游「支付中」,落 next_query_time
└ LsyOrderPollScheduler 接手(§6.5)
下游 POST /payment/openapi/leshouying/qrcode/create
→ 幂等 → 查 mch_no → 落单 CREATED
→ 调 v3/qrpay(无 pay_type 参数,自动识别支付宝/微信)
├ PAY_SUCCESS → 存 code_url + 过期时间 → 返回下游 {out_trade_no, code_url, expire_time}
└ PAY_FAIL → FAIL
用户扫码支付 → 乐收赢回调 notify_url → LsyNotifyService
不同步轮询(§2.1:qrpay 不返回 PAY_IN_PROCESS)。
time_expire 下游可选传入,默认 15 分钟,上限 3 天(4320 分钟,上游硬限制)。
下游 POST /payment/openapi/leshouying/h5/create
→ 幂等 → 查 mch_no → 落单 CREATED
→ 调 v3/h5pay(pay_type 必填 300/400,由调用方传入)
├ PAY_SUCCESS → 存 code_url → 返回下游 {out_trade_no, code_url}
└ PAY_IN_PROCESS / PAY_FAIL → 相应处理
前端 window.location = code_url → 唤起微信/支付宝
用户完成支付 → 乐收赢回调 → LsyNotifyService
不同步轮询:用户被跳转到支付 App 里,停留时长不可控,同步等待无意义。
code_url 直接透传给下游,前端自行跳转。
下游统一入口 POST /payment/openapi/leshouying/order/close,内部按 pay_scene 路由:
被扫 → 撤销(v3/cancelorder),且撤销前先查一次交易:
1. 调 v3/queryorder
2. 若已 PAY_SUCCESS → 不调 cancelorder,转走退款流程(我们明确知道要记 REFUNDED)
3. 若 PAY_IN_PROCESS / PAY_FAIL → 调 v3/cancelorder 关单 → CANCELED
为什么先查:§2.4 说明 cancelorder 对已支付的单会退款,但响应无法区分。
先查一次多花一次调用,换来状态语义确定。
竞态兜底:若查询时还在支付中、调撤销的瞬间用户支付成功,乐收赢会退款并发退款回调
→ 状态从 CANCELED 更正为 REFUNDED(§5.2 的条件更新允许这个流转)。
主扫 → 关单(v3/qrclose),入参是 code_url 不是 trade_no(§2.2)→ CLOSED。
H5 → 无已知关单接口(§2.3)→ 本地置 EXPIRED,待联调确认后再补。
Job 1 — LsyOrderPollScheduler(每 30 秒):处理被扫的密集轮询
条件:status = PAYING AND pay_scene = BARCODE AND next_query_time <= now
→ 调 v3/queryorder
├ 终态 → 条件更新 + 回调下游
└ 仍支付中 → query_count++,next_query_time += 5s
超过 15 次或超过 pay_time + 80s → 触发自动撤销(§6.4 先查后撤)
上游规定的节奏是「5 秒后开始,15 次,间隔 5 秒,最长 ~80 秒」(§3)。 我们的同步 3 次(6 秒)是叠加在前的用户体验优化,量级一致。
Job 2 — LsyReconcileScheduler(每 5 分钟):低频对账兜底
| 处理对象 | 动作 |
|---|---|
PAYING 且 pay_scene IN (QRCODE, H5) 且创建超 60 秒 |
查上游,终态则更新 + 回调下游(兜底回调丢失) |
PAYING 且 pay_scene = QRCODE 且 code_url_expire_time <= now |
调 v3/qrclose → CLOSED |
PAYING 且 pay_scene = H5 且创建超 30 分钟(可配) |
本地置 EXPIRED,不调上游(关单方式未确认) |
| 任意非终态且创建超 24 小时 | 本地置 EXPIRED 兜底收敛 |
notify_status = FAIL 的下游回调 |
重试推送(指数退避,上限 8 次) |
为什么不给主扫/H5 排
next_query_time密轮询:回调是主路径,乐收赢自己会重试回调。 轮询只为兜底"回调彻底丢失",低频扫表就够了。给每个订单排密集轮询会把调度器压垮。
下游 POST /payment/openapi/leshouying/refund
→ 校验:订单 SUCCESS/PARTIAL_REFUNDED,refund_fee <= total_amount - refunded_amount
→ 落 pay_lsy_refund status=CREATED
→ 调 v3/refundorder(注意 pay_time=退款请求时间,pay_trace_time=原支付时间)
├ PAY_SUCCESS → SUCCESS,回写订单 refunded_amount
│ 付满 → REFUNDED;未付满 → PARTIAL_REFUNDED
└ PAY_IN_PROCESS → REFUNDING,由 Job 2 查 v3/queryrefund 推进
退款回调(TRADE_REFUND / TRADE_REFUND_FAIL,带 o_trade_no 原订单号)→ 终局确认
refund_fee 可以小于订单金额 → 支持部分退款;订单累计 refunded_amount 决定最终状态。
LeshouyingNotifyController 是免登录入口,MD5 签名是唯一身份证明。
1. 从入参取出 sign
2. 其余参数:排除 null,保留空字符串(与上游规则一致)
3. 按参数名 ASCII 升序排序
4. 拼 k=v&k=v&...&key={机构密钥}
5. MD5 → 32 位小写,与 sign 比较
6. 用常量时间比较(MessageDigest.isEqual),不用 String.equals
pay_lsy_notify_log(verify_status=FAIL)+ 返回失败,不写任何业务状态{"return_code":"SUCCESS","return_msg":"成功"},
否则乐收赢会持续重试(§7)上游对响应签名的描述是"拼装所有传递参数",而请求侧写的是 "值不为空(为 null 不参与,空字符串参与)"。两者是否同规则文档未明确(§14 待确认项 9)。
在本确认前实现响应验签,一旦拼装规则猜错就会拒绝合法响应 → 支付链路中断。 因此 v1 不实现,明确记录为待确认,确认后再加。
这不是"跳过验签"。入站验签(真正的安全边界)是强制的、无开关的。 出站响应报文本身受 TLS 保护,验签属深度防御,且当前规范不足以实现。
| 文件 | 改什么 | 漏改的后果 |
|---|---|---|
core/security/TenantApiKeyAuthFilter.java:92-96 |
下游 7 个路径逐条加入 OPENAPI_ACCOUNT_PATHS |
shouldNotFilter 用 path.equals(p) 精确匹配,不命中 → 过滤器整体跳过 → 完全无鉴权 |
core/security/SecurityConfig.java:38-70 |
加入 /payment/leshouying/notify/** |
回调被 Spring Security 挡成 403 |
core/tenant/TenantInnerInterceptor.java:53-76 |
4 张新表加入 CONDITIONAL_TABLES |
无认证场景(回调/调度)查询被追加 tenant_id=0 → 回调回写静默失效 |
第三条是本项目已经踩过的坑:pay_batch_authorize 的注释里写着
"此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效,实际靠前端 queryAudit 兜底"。
application.yml / 环境变量,不提供任何接口读写/payment/openapi/leshouying/ 前缀下 7 个 POST 端点:
| 路径 | 用途 |
|---|---|
barcode/pay |
被扫下单 |
qrcode/create |
主扫下单(聚合码) |
h5/create |
H5 下单 |
order/query |
交易查询 |
order/close |
撤销/关单(按 pay_scene 内部路由) |
refund |
申请退款 |
refund/query |
退款查询 |
TenantApiKeyAuthFilter(Authorization: ApiKey {key} + Signature: {HMAC-SHA256 hex})SignatureGenerator,HMAC-SHA256 over ASCII-sorted URL-encoded k=v&...OpenapiService.sendCallback 的表单格式(notify_id / timestamp / content)Result / PageResult关键边界:乐收赢的 MD5 只存在于「我们 → 乐收赢」这一段。 下游面向用户的签名仍是 HMAC-SHA256,协议一行不改。
复用 sendCallback 的报文格式,但增加持久化重试(现有实现对 4xx 不重试、失败即丢):
notify_status + notify_count + last_notify_timenotify_status=FAIL → 由 LsyReconcileScheduler 指数退避重试(上限 8 次)notify_url → 回退 api_key.return_url → 回退 open_conf.return_url沿用 HAR 的落地方式(api/module_payment/har.ts + views/module_payment/har/*):
| 页面 | 内容 |
|---|---|
views/module_payment/leshouying/order/index.vue |
订单列表(场景/状态/金额/时间筛选)+ 详情抽屉(含上游流水号、回调记录、原始报文) |
views/module_payment/leshouying/refund/index.vue |
退款列表 + 详情 |
views/module_payment/leshouying/merchant/index.vue |
商户映射 CRUD(A 阶段必需,否则联调要手工写 SQL 插 mch_no) |
api/module_payment/leshouying.ts + db/lsy-menu.sql(菜单与权限)。
JUnit 5 + Mockito,对齐 HAR 的测试密度(HAR 为 189 个用例全绿)。上游全程 mock,不依赖测试环境可用性。
重点覆盖:
| 目标 | 用例要点 |
|---|---|
LeshouyingSigner |
ASCII 排序;空字符串参与;null 排除;尾部 &key=;32 位小写;验签用常量时间比较;篡改任一字段必失败 |
LeshouyingResponseParser |
v3/* 的 return_code+result_code 与 api/* 的 result_code+result_msg 两套信封分开解析 |
| 状态机 | 条件更新受影响行数为 0 时不触发下游回调;CANCELED → REFUNDED 的更正流转允许 |
| 被扫轮询 | 3 次同步命中终态;6 秒后仍支付中正确落 next_query_time;15 次耗尽触发撤销 |
| 撤销 | 先查后撤:已支付转退款而非调 cancelorder |
| 回调 | 验签失败不写业务状态;重复通知幂等;应答体格式正确 |
| 退款 | 部分退款累计 refunded_amount;pay_time/pay_trace_time 语义不搞反 |
| 幂等 | 同 out_trade_no 重复下单返回原结果而非新建 |
支付相关的 4 条(进件相关的 7 条见参考文档 §26):
h5pay 无对应关单接口,closeorder 正文未提 H5h5pay 的轮询规则 —— §3 的 15×5s 明确针对付款码支付,H5/聚合码未规定v3/qrclose 的返回语义 —— result_code 文档写的是"预支付支付成功/预支付失败",措辞与关单场景不符V1.8__create_pay_lsy_tables.sql 与 lsy-menu.sql 写完即可,
建表/写菜单不可逆,等用户放行(与 HAR 的处理一致)线上版本必须先进件才能用(用户原话:"线上版本肯定是要先进件再用的")。
A 阶段用文档提供的测试商户 Y52211090004 走通支付与收款生命周期。
B 的范围(参考文档 §17-§25):创建/更新/查询商户、图片上传、省市区、行业类目、
审核异步通知 + 主动查询、创建门店/门店列表。产出 mch_no 后写入 pay_lsy_merchant。
B 有 7 条待确认项(参考文档 §26),其中 reviewed_status 值域在三处文档里互相冲突
(异步通知 1通过/2驳回、主动查询 0通过/1审核中/9驳回/10报备中/98停用/99关闭、
示例里出现中文字符串 "审核通过"),不能猜,必须联调确认。
B 有自己的 spec → plan → 实现周期。
| 风险 | 影响 | 缓解 |
|---|---|---|
| H5 关单方式未定义 | 未支付的 H5 订单只能本地置 EXPIRED,上游侧链接仍有效 |
待确认;迟到支付必须可被检出,见下方补注 |
| 响应验签规则不明 | 无法做深度防御 | 入站验签已强制;出站列入待确认 |
cancelorder 语义模糊 |
可能把"退款"误记为"关单" | 先查后撤 + 退款回调更正 |
| 测试环境可用性未知 | 联调阻塞 | 全部单测 mock 上游,不依赖环境 |
| 三处共享文件漏改 | 无鉴权 / 回调 403 / 回写静默失效 | §7.3 列为必改项,测试覆盖 |
| Flyway 版本冲突 | HAR 恢复时迁移报错 | 乐收赢占 V1.8,HAR 改 V1.9(§4.3) |
EXPIRED 之后到达的真实支付(2026-09-21 追加)第 1 行原写的缓解是「EXPIRED 状态可追溯」。这句话本身不足以成立 —— 追溯要靠一条会
留下痕迹的路径,而按原设计那条路径恰恰会把证据抹掉:
EXPIRED 时上游链接仍有效,用户此后仍可真的付款成功;LsyOrderService.applyPayResult 的条件更新放行集合是
PAY_WRITE_FROM = {CREATED, PAYING} —— EXPIRED 不在其中,条件更新影响 0 行;DUPLICATE,与「上游重推」无法区分。于是「钱在上游收了、我们账上是已过期」这件差异,唯一的证据被记成了一条正常的重复回调。
因此把缓解显式化为一条可验证的要求:回调侧必须把「0 行 + 上游报 PAY_SUCCESS +
本单已落终态且不是 SUCCESS」与真·重复回调分开,以 ERROR 级别 + 独立审计结果
LATE_PAYMENT_AFTER_TERMINAL 暴露出来(实现见计划 Task 12)。
v1 只暴露、不自动纠正。 受控的 EXPIRED → SUCCESS 纠正通道要等第 11 节第 1 项
(H5 关单方式)与第 3 项(H5 轮询规则)确认后再定:在不知道上游对「已过期订单」的
回调语义之前,自动改写资金终态比不纠正更危险。在此之前,这笔差异靠人工核对兜底。