Browse Source

test(乐收赢): 入站验签补第四条向量 —— 上游对**空字符串**签名的真实应答

2026-09-28 用测试环境发了一次真实 POST /v3/qrpay(1 分钱聚合码单),上游回 370 字节、
result_code=PAY_SUCCESS。应答里 shop_name / shop_no 都是**空串而不是 null**
(我们没发 shop_no,生产侧是「非 null 才发」),而这两个空串**都在上游的签名原文里**。
逐字抄进用例喂 verify(),必须被认。

为什么单列这一条:文档 §3 第一步把「空串参与、null 不参与」并列写着,但此前
「空串参与」只有**自证型**覆盖(sign_emptyStringParticipates 的期望值由我们自己按同一条
规则算出)——自证答不了「上游是不是也这么算」。它与 verify_acceptsRealUpstreamResponseFromTestEnv
**互补,缺一不可**:那条钉 null 排除(26 个字段里 24 个是 null),这条钉空串保留。
只留任一条的话,把 buildSource 改成「空串也排除」不会有任何用例变红,
而入站验签会当场拒掉真实回调 —— 而验签是回调入口的唯一身份证明。

两向变异(改 buildSource 的两个 filter):
- A 空串也排除 ⇒ 红 2 条(sign_emptyStringParticipates + 本条),null 那条向量仍绿
- B 两个 filter 互换(语义等价)⇒ 该类 14 条全绿
- 还原后逐字节相同:sha256 a85d881b… / 5806 B / git diff 相对 HEAD 0 行

全量 902 tests, 0 failures, 0 errors, 0 skipped。两条独立量具同读数:
Maven 控制台一行 + surefire XML 逐文件求和(69 个 testsuite / 902)。

顺带订正一处不实注释:sign_matchesDocumentedParamSet 原写「文档只给了 A,没给最终 sign」
——**不实**,文档 §3 第五步给了一个最终 sign(841387ec9bd3ea78a580bd4629cab010,配 §3 第三步的
示例密钥,与那段 A 正是同一组参数)。但它**复现不出来**:按文档自己那四步算是
92bbe9b73e5657f8a5cdcae215c1d697(本次实测)。所以仍旧不能拿它当向量,注释已按实测改写。

另记一条对账路径的事实(写进用例注释):qrpay 的返回表本就**没有 trade_no**,
所以主扫单被对账捞起时,LsyQueryService.queryOrder 走的一定是 pay_trace_no + pay_time 那条分支。

仅测试改动,无生产代码变化(生产文件 sha256 与 HEAD 一致)。
alphaH 2 weeks ago
parent
commit
e753839faf

+ 56 - 2
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java

@@ -35,8 +35,12 @@ class LeshouyingSignerTest {
         //   auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004
         //   &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e
         //   &pay_type=400&total_amount=1
-        // 文档只给了 A,没给最终 sign,所以这里断言的是「A 的拼装结果」——
-        // 用 md5(A + "&key=" + KEY) 反推,验证排序与拼接格式与文档一致。
+        // 【2026-09-28 订正】此处原写「文档只给了 A,没给最终 sign」——**不实**:文档 §3 第五步
+        // 给了一个最终 sign(841387ec9bd3ea78a580bd4629cab010,配 §3 第三步的示例密钥
+        // 8a9bd64e6585c6d8016585c6c8980002,与上面这个 A 正是同一组参数)。但它**复现不出来**:
+        // 按文档自己的四步算是 92bbe9b73e5657f8a5cdcae215c1d697(本次实测)。那条示例值只作示意,
+        // **别拿它当向量**。这里断言的仍是「A 的拼装结果」—— md5(A + "&key=" + KEY),
+        // 验证排序与拼接格式与文档一致。
         Map<String, Object> p = params(
                 "auth_code", "134573607383541818",
                 "total_amount", "1",
@@ -231,4 +235,54 @@ class LeshouyingSignerTest {
         resp.put("return_msg", "订单信息存在!");
         assertFalse(signer.verify(resp, KEY), "反向守卫:改动被签字段必须被拒");
     }
+
+    /**
+     * ★ <b>第四条独立向量,也是唯一一条「上游对空字符串签名」的向量</b> —— 2026-09-28 真实主扫下单。
+     *
+     * <p>用测试环境发了一次真实的 {@code POST /v3/qrpay}(1 分钱的聚合码单),上游回 370 字节。
+     * 这条应答里有<b>两个空字符串字段</b>:{@code shop_name} 与 {@code shop_no}
+     * (我们没发 shop_no —— 生产侧「非 null 才发」—— 上游回的是<b>空串而不是 null</b>),
+     * 而这两个空串<b>都在签名原文里</b>。
+     *
+     * <p>★ <b>为什么非要有这一条</b>:文档 §3 第一步把「参数值为空字符串参与,参数值为 null 不参与」
+     * 并列写着,但本类此前对「空串参与」只有<b>自证型</b>用例
+     * ({@link #sign_emptyStringParticipates}:期望值由我们自己按同一条规则算出)。自证型用例
+     * 答不了「上游是不是也这么算」。这条是外部证据 —— 签名是上游自己做的。
+     *
+     * <p>★ 与 {@link #verify_acceptsRealUpstreamResponseFromTestEnv} <b>互补,缺一不可</b>:
+     * 那条钉的是 <b>null 排除</b>(26 个字段里 24 个是 null),这条钉的是 <b>空串保留</b>。
+     * 两条合起来才覆盖文档那句话的两半。只留任一条的话,把 {@code buildSource()} 改成
+     * 「空串也排除」不会有任何用例变红,而入站验签会当场拒掉真实回调。
+     *
+     * <p>顺带钉住一条对账路径的事实:主扫下单成功时 {@code result_code=PAY_SUCCESS},
+     * 而<b>应答里没有 trade_no</b>(qrpay 的返回表本就没有这个字段)。因此主扫单被对账捞起时,
+     * {@code LsyQueryService.queryOrder} 走的一定是 {@code pay_trace_no + pay_time} 那条分支。
+     *
+     * <p>向量稳定性:全部 11 个字段都是那一笔真实订单的数据,不再变化 ⇒ sign 跨运行恒定。
+     */
+    @Test
+    void verify_acceptsRealQrpayResponse_whereUpstreamSignedEmptyStrings() {
+        // 逐字抄自上游应答(字段顺序照原文;两个空串与那串中文都是上游真发的)
+        Map<String, Object> resp = params(
+                "return_code", "SUCCESS",
+                "return_msg", "聚合码请求成功!",
+                "result_code", "PAY_SUCCESS",
+                "mch_name", "建行小店基本休息修改",
+                "shop_name", "",
+                "mch_no", "Y52211090004",
+                "shop_no", "",
+                "pay_trace_no", "probe20260928152205qr",
+                "pay_time", "20260928152205",
+                "total_amount", "1",
+                "code_url", "https://test.gdyfsk.com/yfpay/s/s-00002");
+        resp.put("sign", "00f5d5cb5c37a81ea40a5c35471ad0aa");
+
+        assertTrue(signer.verify(resp, KEY), "上游真实应答(含空串字段)必须被认");
+
+        // 反向守卫:把两个空串之一改成有值 ⇒ 签名原文变了 ⇒ 必须被拒。
+        // 这条守卫**专门**压空串那一格:若 buildSource 哪天把空串当 null 剔掉,
+        // 上面那句会红;若改成「空串与缺值等价」,这句会红。
+        resp.put("shop_no", "86000033007");
+        assertFalse(signer.verify(resp, KEY), "反向守卫:空串改成有值必须被拒");
+    }
 }