2026-09-21-leshouying-payment-channel-design.md 27 KB

乐收赢支付通道 设计文档

日期:2026-09-21 状态:待实现 上游文档:https://doc.leshouying.cn/web/#/611206641/196236533 接口摘录:D:\project2\leshouying-api-docs.md(30 节,含完整目录树与逐页参数)


1. 背景与范围

1.1 目标

把乐收赢接入为上游支付通道,并包装成下游支付 API 提供给我们平台的租户使用。三层结构:

乐收赢(上游 PSP)
   ↕  MD5 签名(机构密钥)· /v3/* 与 /api/* 两套报文
我们平台(java/,Spring Boot 3.2.5)
   ↕  ApiKey + HMAC-SHA256 · 复用现有机制,不改协议
我们的用户(租户 / 企业)

1.2 本次交付(子项目 A)

场景 上游接口 触发方式
付款码支付(被扫 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 个调度任务、前端订单/退款/商户映射管理页。

1.3 明确不做

项 原因
JSAPI 支付 v3/jspay 微信内置浏览器场景,需 open_id + 服务商公众号授权,链路长一个量级。用户已确认本期不做
小程序支付 / 小程序数币 无场景
云订单支付 无场景
总分店模式(*【总分店模式】 三个接口) 单机构单商户模式,无总分公司场景
分账 share_type / share_detail 无场景
营销 / 电子发票 / IOT / 会员 / 对账接口 不属于本次范围
商户进件全套(/api/merchant/*、/api/shop/*、省市区、行业类目等) 子项目 B,见 §13
龙支付(pay_type=900)、云闪付(600,仅 H5 支持) 无场景

2. 关键调研结论(推翻/修正了若干初判)

这四条是设计的前提,都在上游文档里有明确出处:

2.1 qrpay 不返回 PAY_IN_PROCESS

§11 聚合码返回参数只有 PAY_SUCCESS / PAY_FAIL。主扫下单是同步确定的,不需要同步轮询。

2.2 主扫关单是 v3/qrclose,入参是 code_url 而非 trade_no

§29 / [page 196236547]。「聚合码支付」场景介绍 [page 196236545] 的 API 列表里 只有 qrpay / queryorder / refundorder,没有 closeorder。 §6 通用 closeorder 正文写的是"二维码支付、JSAPI支付可用"。 → 走 qrpay 下单的订单,关单必须用 qrclose + code_url。

2.3 h5pay 会返回 PAY_IN_PROCESS,且无已知关单接口

§27。H5 目录下只有「H5支付下单」一页;§6 closeorder 未提及 H5。 → H5 订单的关单方式未定义,列入待确认(§14),v1 用本地状态收敛兜底(§6.4)。

2.4 撤销接口无法区分"关单"还是"退款"

§5:撤销对已支付的单会把资金退还给用户,但返回只有 PAY_SUCCESS / PAY_FAIL。 → 直接调 cancelorder 会导致本地状态记错。设计用"撤销前先查"绕开(§6.3)。


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 若把三种下单 + 状态机 + 轮询判定全塞进去会超千行。 下单入口按场景分方法但共用一个状态机;查询/撤销/退款各自独立,便于单独测试。


4. 数据模型

4.1 凭证归属

inst_no + 机构密钥是平台级的——我们是一个机构,各企业在它下面开商户。 因此放 LeshouyingProperties(application.yml),沿用 HAR HarProperties 的做法,不入库,避免机构密钥明文落库。

只有 mch_no / shop_no 是每企业一份,入表。

4.2 四张表

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),日志保留完整重试轨迹便于对账。

4.3 迁移脚本

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 的写法)。


5. 状态机

5.1 订单状态

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。

5.2 幂等:条件更新 + 受影响行数

所有状态流转一律走条件更新,且按流转方向使用不同的放行集合:

流转 放行来源状态 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')
  • 受影响行数 = 1 → 真流转 → 才触发下游回调
  • 受影响行数 = 0 → 重复通知 / 竞态 / 状态不匹配 → 静默忽略,不重复回调下游

两个集合互不重叠:支付回调永不覆盖已退款订单,退款回调也永不把非支付单改成退款态。

这样重复通知天然成为 no-op,且不误伤"更正型"通知——例如撤销后收到退款回调需要 CANCELED → REFUNDED,若用唯一约束硬挡或共用同一个来源集合就会丢数据。


6. 核心流程

6.1 被扫(唯一需要同步轮询的场景)

下游 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)

6.2 主扫(PC 网页扫码)

下游 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 分钟,上游硬限制)。

6.3 H5(移动端浏览器拉起)

下游 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 直接透传给下游,前端自行跳转。

6.4 撤销 / 关单(按场景路由)

下游统一入口 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,待联调确认后再补。

6.5 轮询与兜底

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 密轮询:回调是主路径,乐收赢自己会重试回调。 轮询只为兜底"回调彻底丢失",低频扫表就够了。给每个订单排密集轮询会把调度器压垮。

6.6 退款

下游 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 决定最终状态。


7. 安全设计

7.1 入站回调验签 —— 强制,无任何跳过路径

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)+ 返回失败,不写任何业务状态
  • 不提供任何 skip / disable / 开关 / 测试后门。用户明确要求"验签不能跳过",实现里写死
  • 回调应答必须返回 {"return_code":"SUCCESS","return_msg":"成功"}, 否则乐收赢会持续重试(§7)

7.2 出站响应验签 —— 本期不实现,列入待确认

上游对响应签名的描述是"拼装所有传递参数",而请求侧写的是 "值不为空(为 null 不参与,空字符串参与)"。两者是否同规则文档未明确(§14 待确认项 9)。

在本确认前实现响应验签,一旦拼装规则猜错就会拒绝合法响应 → 支付链路中断。 因此 v1 不实现,明确记录为待确认,确认后再加。

这不是"跳过验签"。入站验签(真正的安全边界)是强制的、无开关的。 出站响应报文本身受 TLS 保护,验签属深度防御,且当前规范不足以实现。

7.3 三个共享文件的必改点

文件 改什么 漏改的后果
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 兜底"。

7.4 其他

  • 付款码不落库(§4.2),日志打码
  • 回调日志表记录原始报文,便于对账与争议取证
  • 下游回调地址在下单时快照到订单上,避免商户改配置导致历史订单回调漂移
  • 机构密钥只能来自 application.yml / 环境变量,不提供任何接口读写

8. 下游 OpenAPI

8.1 端点

/payment/openapi/leshouying/ 前缀下 7 个 POST 端点:

路径 用途
barcode/pay 被扫下单
qrcode/create 主扫下单(聚合码)
h5/create H5 下单
order/query 交易查询
order/close 撤销/关单(按 pay_scene 内部路由)
refund 申请退款
refund/query 退款查询

8.2 复用现有机制,不新造

  • 认证: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,协议一行不改。

8.3 回调我们的下游

复用 sendCallback 的报文格式,但增加持久化重试(现有实现对 4xx 不重试、失败即丢):

  • 订单/退款单记 notify_status + notify_count + last_notify_time
  • 推送失败 → notify_status=FAIL → 由 LsyReconcileScheduler 指数退避重试(上限 8 次)
  • 回调地址:下单时快照的 notify_url → 回退 api_key.return_url → 回退 open_conf.return_url

9. 前端

沿用 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(菜单与权限)。


10. 测试策略

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 重复下单返回原结果而非新建

11. 待与乐收赢确认

支付相关的 4 条(进件相关的 7 条见参考文档 §26):

  1. H5 订单如何关闭 —— h5pay 无对应关单接口,closeorder 正文未提 H5
  2. 响应报文签名拼装规则 —— 请求侧"值不为空",响应侧"所有传递参数",是否同规则
  3. h5pay 的轮询规则 —— §3 的 15×5s 明确针对付款码支付,H5/聚合码未规定
  4. v3/qrclose 的返回语义 —— result_code 文档写的是"预支付支付成功/预支付失败",措辞与关单场景不符

12. 交付约束

  • 一行都不 commit —— 只落工作区。用户明确要求,与 HAR 交付时一致
  • SQL 不执行 —— V1.8__create_pay_lsy_tables.sql 与 lsy-menu.sql 写完即可, 建表/写菜单不可逆,等用户放行(与 HAR 的处理一致)
  • 验签不可跳过 —— 无任何 skip/disable 开关(§7.1)

13. 子项目 B:商户进件(后续)

线上版本必须先进件才能用(用户原话:"线上版本肯定是要先进件再用的")。 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 → 实现周期。


14. 风险登记

风险 影响 缓解
H5 关单方式未定义 未支付的 H5 订单只能本地置 EXPIRED,上游侧链接仍有效 待确认;迟到支付必须可被检出,见下方补注
响应验签规则不明 无法做深度防御 入站验签已强制;出站列入待确认
cancelorder 语义模糊 可能把"退款"误记为"关单" 先查后撤 + 退款回调更正
测试环境可用性未知 联调阻塞 全部单测 mock 上游,不依赖环境
三处共享文件漏改 无鉴权 / 回调 403 / 回写静默失效 §7.3 列为必改项,测试覆盖
Flyway 版本冲突 HAR 恢复时迁移报错 乐收赢占 V1.8,HAR 改 V1.9(§4.3)

补注:H5 本地 EXPIRED 之后到达的真实支付(2026-09-21 追加)

第 1 行原写的缓解是「EXPIRED 状态可追溯」。这句话本身不足以成立 —— 追溯要靠一条会 留下痕迹的路径,而按原设计那条路径恰恰会把证据抹掉:

  1. H5 无上游关单接口 → 本地置 EXPIRED 时上游链接仍有效,用户此后仍可真的付款成功;
  2. 该支付的回调到达时,LsyOrderService.applyPayResult 的条件更新放行集合是 PAY_WRITE_FROM = {CREATED, PAYING} —— EXPIRED 不在其中,条件更新影响 0 行;
  3. 回调侧原先把这个 0 行统一记为审计结果 DUPLICATE,与「上游重推」无法区分。

于是「钱在上游收了、我们账上是已过期」这件差异,唯一的证据被记成了一条正常的重复回调。

因此把缓解显式化为一条可验证的要求:回调侧必须把「0 行 + 上游报 PAY_SUCCESS + 本单已落终态且不是 SUCCESS」与真·重复回调分开,以 ERROR 级别 + 独立审计结果 LATE_PAYMENT_AFTER_TERMINAL 暴露出来(实现见计划 Task 12)。

v1 只暴露、不自动纠正。 受控的 EXPIRED → SUCCESS 纠正通道要等第 11 节第 1 项 (H5 关单方式)与第 3 项(H5 轮询规则)确认后再定:在不知道上游对「已过期订单」的 回调语义之前,自动改写资金终态比不纠正更危险。在此之前,这笔差异靠人工核对兜底。