# 乐收赢支付通道 设计文档 **日期**: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 轮询规则)确认后再定:在不知道上游对「已过期订单」的 回调语义之前,自动改写资金终态比不纠正更危险。**在此之前,这笔差异靠人工核对兜底。**