Quellcode durchsuchen

feat(进件): LeshouyingClient 补 /api/* 出站腿 - 信封与 /v3/* 分开

alphaH vor 3 Wochen
Ursprung
Commit
ebeb18f8b7

+ 52 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java

@@ -75,6 +75,57 @@ public class LeshouyingClient {
      * </ul>
      */
     public Map<String, Object> post(String path, Map<String, Object> bizParams) {
+        return parser.parseV3(postForRawJson(path, bizParams));
+    }
+
+    /**
+     * 调用 /api/*(进件类)接口。
+     *
+     * <p>与 {@link #post} 共用**整条出站链路**(剔 null → 补 inst_no → 签名 → 出站 trace 日志 →
+     * 同一套四分支异常包装),唯一差别是末行的解析器:这里用
+     * {@link LeshouyingResponseParser#parseApi},而那一条用 {@code parseV3}。
+     *
+     * <p>【为什么是两个方法,不是一个带 boolean 的方法】信封是**协议事实**,不是调用方的开关。
+     * `/v3/*` 用 `return_code` 表通信、`result_code` 表业务(PAY_SUCCESS / PAY_IN_PROCESS / PAY_FAIL);
+     * `/api/*` 用 `result_code` 表成败、`result_msg` 表消息。两套字段**同名而语义不同**,
+     * 一个端点属于哪一套由上游的路径前缀决定,调用方没有选择权 —— 也就是说,
+     * 布尔参数表达的从来不是「调用方想要哪个」,而是「这个端点恰好是哪个」,后者本可以写死在方法里。
+     * 写成 {@code post(path, params, true)} 时,读代码的人从调用点看不出 `true` 指哪一边;
+     * 而传错的那一次**不会**报「你用错了信封」,它会报 `PARSE_ERROR`:
+     * 一次成功的进件(`{"result_code":"SUCCESS"}`,没有 `return_code`)落进 parseV3 的
+     * 「缺 return_code ⇒ 非 v3 响应」分支,于是「进件失败」被读成「解析错误」,排查方向直接跑偏。
+     * 两个方法名把选择固定在**被调用方**,调用点自解释,传错这件事从语法上就不存在。
+     *
+     * <p>进件类的失败语义与 v3 不同:非 SUCCESS **一律抛** {@link LeshouyingApiException}
+     * —— 逐字取自 {@link LeshouyingResponseParser#parseApi} 的既定契约「进件类没有独立的『业务失败』语义」,
+     * 所以在进件这条腿上,能被 `catch` 接住的失败和「上游拒绝」是同一件事,不存在 v3 那种
+     * 「抛异常 = 通信层失败 / 正常返回 = 业务结果」的二分。
+     *
+     * <p>值序列化契约与 {@link #post} 逐字相同(见其 javadoc):进 map 的每个值都必须是
+     * **最终字符串形状** —— 日期 `yyyyMMddHHmmss`、金额「分」的字符串,不得放
+     * `LocalDateTime` / `Date`。
+     *
+     * @param path      以 / 开头的路径,如 "/api/queryArea"
+     * @param bizParams 业务参数(不含 inst_no / sign,本方法自动补)
+     */
+    public Map<String, Object> postApi(String path, Map<String, Object> bizParams) {
+        return parser.parseApi(postForRawJson(path, bizParams));
+    }
+
+    /**
+     * 两条腿共用的出站链路:拼 payload(剔 null + 补 inst_no + 签名)→ 出站 trace 日志 →
+     * POST → 四分支异常包装 → **返回未经解析的响应体原文**。
+     *
+     * <p>它刻意**不解析**:解析就是对两套信封做选择的地方,那是 {@link #post} 与
+     * {@link #postApi} 唯一各写各的一行。把解析放进这里就必然要在这里再分一次支,
+     * 等于把刚拆开的选择题换个地方合回去。
+     *
+     * <p>也刻意**不合并**两处的差异:本方法对 `/v3/*` 与 `/api/*` 一视同仁(上游两套信封的
+     * 请求侧确实同构)。若将来某一套的请求侧出现分叉(例如进件要额外带 app_id),
+     * 正确做法是在这里显式加形参,而不是把整段抄一份 —— 抄一份就等于把这段
+     * 「4xx/5xx 响应体不得随异常外流」的安全防线也复制成两份,将来只加固其中一份。
+     */
+    private String postForRawJson(String path, Map<String, Object> bizParams) {
         Map<String, Object> payload = new LinkedHashMap<>();
         if (bizParams != null) {
             // null 值不上送(与签名规则一致,避免产生歧义)
@@ -164,7 +215,7 @@ public class LeshouyingClient {
             throw ex;
         }
 
-        return parser.parseV3(json);
+        return json;
     }
 
     /** 机构号,供调用方拼装查询流水号等场景使用 */

+ 136 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientApiEnvelopeTest.java

@@ -0,0 +1,136 @@
+package com.payment.platform.module.payment.leshouying.client;
+
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 钉住 `LeshouyingClient.postApi` —— 进件类 `/api/*` 出站腿用的是**另一套信封**。
+ *
+ * 【为什么值得单开一个测试类】两条腿共用同一段「补 inst_no → 签名 → POST → 异常包装」,
+ * 唯一的差别只有最后一行的解析器(`parseV3` vs `parseApi`)。正因差别只有一个词,
+ * 它恰恰是最容易被一次重构抹平的地方 —— 而抹平之后的行为是:
+ * 一次**成功的进件**(`/api/*` 回 `{"result_code":"SUCCESS"}`,**没有** `return_code`)
+ * 会走进 parseV3 的「缺少 return_code」分支,抛 `PARSE_ERROR`。
+ * 调用方看到的是「解析错误」,真实原因却是「信封选错了」—— 排查方向直接跑偏。
+ *
+ * 三个用例各自钉一件事,见每个方法上的注释;第 4 个用例钉的是全局约束
+ * 「验签绝不跳过」在**新增的这条腿**上也成立(共用 helper 不等于共用签名,
+ * 将来有人给 postApi 另写一份 payload 拼装就会漏签)。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingClientApiEnvelopeTest {
+
+    @Mock private RestTemplate restTemplate;
+    private LeshouyingProperties props;
+    private LeshouyingClient client;
+
+    @BeforeEach
+    void setUp() {
+        props = new LeshouyingProperties();
+        props.setBaseUrl("https://test.gdyfsk.com/yfpay");
+        props.setInstNo("900112");
+        // ★ 占位密钥,**故意不是**任何真实凭据。签名器对 key 只做拼接(不校验形状),
+        //   所以本类能钉住「确实签了、且签的是 props 里那个 key」而不必把真实机构密钥
+        //   再抄一份进测试文件(全局约束:凭据只打印长度、不落值)。
+        props.setKey("task-1-placeholder-key-not-a-credential");
+        client = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
+                new LeshouyingResponseParser(), props);
+    }
+
+    /**
+     * ★ 这条钉的是**信封选择**本身。
+     * 桩回的报文里**只有** `result_code`(进件类信封的形状),没有 `return_code`。
+     * 于是:postApi 走 parseApi ⇒ 正常返回;postApi 误走 parseV3 ⇒ 抛 PARSE_ERROR
+     * (parseV3 的第一条分支就是「缺 return_code ⇒ 非 v3 响应」)。
+     * 两种实现的差别在返回值上直接可见,用例不会两边都绿。
+     */
+    @Test
+    void postApi_parsesApiEnvelope_notV3() {
+        String body = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"area_list\":[]}";
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>(body, HttpStatus.OK));
+
+        Map<String, Object> resp = client.postApi("/api/queryArea", Map.of("foo", "bar"));
+
+        assertEquals("SUCCESS", resp.get("result_code"));
+    }
+
+    /**
+     * 进件类的失败是**失败码**,不是业务结果:非 SUCCESS 必须抛异常,
+     * 且 `result_msg` 要能到达调用方(进件失败的原因全在这一句里,丢了就只能让上游查单)。
+     */
+    @Test
+    void postApi_failure_throwsWithResultMsg() {
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>(
+                        "{\"result_code\":\"FAIL\",\"result_msg\":\"参数不合法\"}", HttpStatus.OK));
+
+        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
+                () -> client.postApi("/api/queryArea", Map.of("foo", "bar")));
+
+        assertTrue(String.valueOf(ex.getMessage()).contains("参数不合法"),
+                "getMessage() 里必须带上 result_msg,实际: " + ex.getMessage());
+        // 同时钉住错误码来源:必须是上游给的 FAIL,而不是 PARSE_ERROR
+        // (后者意味着报文根本没被当成 api 信封读,那样 message 里那句 result_msg 只是碰巧)
+        assertEquals("FAIL", ex.getRawReturnCode());
+    }
+
+    /**
+     * ★ **正对照**:证明两条腿没有共用同一个解析器。
+     * 同一份「只有 result_code」的报文交给 `post()`(/v3/* 腿)必须**被拒**。
+     * 没有这条,M-1 式的「两条腿都接 parseApi」会被上面两条用例放过 ——
+     * 那正是「把 v3 的 PAY_SUCCESS 判成进件失败」的反向事故。
+     */
+    @Test
+    void post_stillUsesV3Envelope_apiEnvelopeIsRejected() {
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>("{\"result_code\":\"SUCCESS\"}", HttpStatus.OK));
+
+        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
+                () -> client.post("/v3/pay", Map.of("mch_no", "Y52211090004")));
+
+        assertEquals("PARSE_ERROR", ex.getRawReturnCode());
+    }
+
+    /**
+     * 全局约束「验签绝不跳过」要**逐条腿**成立。
+     * 三条信封用例都对「出站报文长什么样」零断言:一个把 postApi 写成
+     * 「直接发原样 bizParams、不补 inst_no 也不签名」的实现照样能全绿。
+     * 而漏签的后果不是报错而是**静默**——上游只会回一句笼统的失败。
+     */
+    @Test
+    void postApi_injectsInstNoAndSign_automatically() {
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>(
+                        "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}", HttpStatus.OK));
+
+        client.postApi("/api/queryArea", Map.of("foo", "bar"));
+
+        ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
+        verify(restTemplate).postForEntity(
+                eq("https://test.gdyfsk.com/yfpay/api/queryArea"), captor.capture(), eq(String.class));
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = (Map<String, Object>) captor.getValue().getBody();
+        assertEquals("900112", sent.get("inst_no"));
+        assertNotNull(sent.get("sign"));
+        assertTrue(new LeshouyingSigner().verify(sent, props.getKey()));
+    }
+}