Quellcode durchsuchen

fix(汇安融): 门店创建 URL 改成 /api/merchant/shop/create —— 上游把文档里的路径给错了

上游 2026-09-24 澄清:门店创建的 url 不是规范 docx 表 1 写的 /api/shop/create,而是
/api/merchant/shop/create。我方一直照抄 docx,这条腿从来没通过 —— 之前九条 curl 全 404
也就解释得通了:排除掉的是错路径,不是「上游没有路由」这个结论。

(这是那份 docx 第三次对不上线上:URL 列漏 /kf-alipay-api 前缀、§58 示例漏 alipay 段,
这次是门店路径。)

只改 url,不动 method。同一张表另四行 method 与 url 严格同构(kf.X.Y.Z ↔ /api/X/Y/Z),
只有门店那行两列同时破例;按同构推 method 本该是 kf.merchant.shop.create —— 但上游只更正了
url、没提 method,所以把已知错的那半先改对,另一半留作未验证项,由联调读数判:
回「method 不存在」就改 METHOD_SHOP_CREATE 那一行,不是把 path 改回去。判据写在
HarClient#createShop 的注释里。

补一条出站契约测试 —— 本仓第一次真的去看上游 URL。HarClient 在三处服务测试里全是 mock,
「路径写错」这类缺陷在测试里完全不可见。台子用 JDK 自带的 com.sun.net.httpserver
(pom 里没有 MockWebServer/WireMock,不为一条断言引新依赖),断言真实收到的 URI 与表单。

两向读数(logs/_har-shop-path-full.log):
- 未改生产码 ⇒ createShop 那条红,expected /api/merchant/shop/create but was /api/shop/create;
  同台的 query 正对照绿 ⇒ 台子读得到出站路径,红的确实是被测代码
- 改后全量 848 绿、0 失败(改前基线 846)

生效需要重新部署(deploy.sh)。生效前门店创建仍打到旧路径。
alphaH vor 3 Wochen
Ursprung
Commit
e57a82b709

+ 7 - 1
docs/superpowers/specs/2026-09-16-har-alipay-channel-design.md

@@ -86,10 +86,16 @@
 | 收款(被扫) | `kf.alipay.trade.pay` | `/api/alipay/trade/pay` |
 | 查询 | `kf.alipay.trade.query` | `/api/alipay/trade/query` |
 | 退款(本期不实现) | `kf.alipay.trade.refund` | `/api/alipay/trade/refund` |
-| 门店创建 | `kf.har.shop.create` | `/api/shop/create` |
+| 门店创建 | `kf.har.shop.create` | `/api/merchant/shop/create` |
 
 文档 §5.1/§5.2 的 `method` 备注写成 `kf.jh.stage.trade.*`,与 §4 接口声明表的 Application 列矛盾。demo(`Main.java:13`)传的是 `kf.alipay.trade.query`,故以 `kf.alipay.trade.*` 为准。
 
+> **2026-09-24 订正**:门店创建的 url 上游口头澄清为 `/api/merchant/shop/create`,规范 docx 表 1 的 URL 列(`/api/shop/create`)是上游写错了——上表该行已按澄清值更新。
+>
+> 同一张表的另四行 method 与 url 严格同构(`kf.X.Y.Z` ↔ `/api/X/Y/Z`),只有门店那行两列同时破例;按同构推 method 本该是 `kf.merchant.shop.create`。但上游**只更正了 url、没提 method**,故 `kf.har.shop.create` 暂留原值——**这是未验证项**,由联调读数判(见 `HarClient#createShop` 的注释与 `HarClientOutboundUrlTest`)。
+>
+> 这是这份 docx 第三次被证实与线上不一致(前两次:URL 列漏 `/kf-alipay-api` 前缀、§58 示例漏 `alipay` 段)。读它的任何一列前先假设可能是旧版。
+
 ### 3.3 响应
 
 - `response_code`:`10000` 表示成功

+ 13 - 1
java/src/main/java/com/payment/platform/module/payment/har/client/HarClient.java

@@ -68,8 +68,20 @@ public class HarClient {
         return invoke(METHOD_QUERY, "/api/alipay/trade/query", req);
     }
 
+    /**
+     * 门店创建。
+     *
+     * <p>URL 是 {@code /api/merchant/shop/create},**不是**规范 docx 表 1 里写的
+     * {@code /api/shop/create} —— 2026-09-24 上游口头澄清「他们提供错了」。
+     *
+     * <p>这张表的另外四行 method 与 url 严格同构({@code kf.X.Y.Z} ↔ {@code /api/X/Y/Z}),
+     * 只有门店那行两列同时破例;按同构推,method 本该是 {@code kf.merchant.shop.create}。
+     * 但上游只更正了 url,没提 method,所以这里**只改 url**:把已知错的那半先改对,
+     * 另一半留给联调读数判(同构是推测,不是实测)。若上游回「method 不存在」,
+     * 要改的是 {@link #METHOD_SHOP_CREATE} 那一行,不是把 path 改回去。
+     */
     public HarResponse createShop(HarShopCreateRequest req) {
-        return invoke(METHOD_SHOP_CREATE, "/api/shop/create", req);
+        return invoke(METHOD_SHOP_CREATE, "/api/merchant/shop/create", req);
     }
 
     /**

+ 150 - 0
java/src/test/java/com/payment/platform/module/payment/har/client/HarClientOutboundUrlTest.java

@@ -0,0 +1,150 @@
+package com.payment.platform.module.payment.har.client;
+
+import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.client.dto.HarShopCreateRequest;
+import com.payment.platform.module.payment.har.config.HarProperties;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * 出站 URL 契约 —— 钉住 {@link HarClient} 真正打到上游的那个 path。
+ *
+ * <p>补这条的原因:本轮联调发现门店创建的 URL 一直是错的(见下),而已有用例里
+ * {@code HarClient} 在三处服务测试里全是 mock,**没有任何用例看过出站 URL** ——
+ * 所以「路径写错」这一类缺陷在本仓的测试里是完全不可见的。
+ *
+ * <p>台账:规范 docx 的接口声明表(表 1)四列形态如下,前四行 method 与 url 严格同构
+ * ({@code kf.X.Y.Z} ↔ {@code /api/X/Y/Z}),第五行两列同时破例:
+ * <pre>
+ *   订单创建  kf.alipay.trade.precreate  /api/alipay/trade/precreate
+ *   扫码收款  kf.alipay.trade.pay        /api/alipay/trade/pay
+ *   订单查询  kf.alipay.trade.query      /api/alipay/trade/query
+ *   订单退款  kf.alipay.trade.refund     /api/alipay/trade/refund
+ *   门店创建  kf.har.shop.create         /api/shop/create      ← 上游 2026-09-24 澄清:url 写错了
+ * </pre>
+ * 上游澄清的正确 url 是 {@code /api/merchant/shop/create}。
+ *
+ * <p><b>method 那半仍是未验证的</b>:上游只更正了 url,没提 method。所以这条用例把
+ * method 单独断言、单独写失败消息 —— 如果联调回的是「method 不存在」,要改的就是那一行,
+ * 而不是把 path 改回去。
+ *
+ * <p>台子用 JDK 自带的 {@code com.sun.net.httpserver}(pom 里没有 MockWebServer/WireMock,
+ * 不为一条断言引新依赖),记录**真实收到**的 URI 与表单,而不是去断言我们自己的构造过程。
+ */
+class HarClientOutboundUrlTest {
+
+    /** 只为让 {@link HarSigner#sign} 有钥匙可用;本地生成,与被测行为无关。 */
+    private static final String TEST_PRIVATE_KEY_B64 = generatePkcs8Base64();
+
+    private HttpServer server;
+    private HarProperties props;
+    private final AtomicReference<String> lastPath = new AtomicReference<>();
+    private final AtomicReference<Map<String, String>> lastForm = new AtomicReference<>();
+
+    @BeforeEach
+    void setUp() throws IOException {
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/", this::handle);
+        server.start();
+
+        props = new HarProperties();
+        props.setEnabled(true);
+        props.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
+        props.setAppId("test-app");
+        props.setVersion("2.0");
+        props.setOurPrivateKey(TEST_PRIVATE_KEY_B64);
+    }
+
+    @AfterEach
+    void tearDown() {
+        server.stop(0);
+    }
+
+    @Test
+    void query_hitsTheAlipayPath_positiveControl() {
+        HarQueryRequest req = new HarQueryRequest();
+        req.setOrderNo("1111");
+
+        new HarClient(props).query(req);
+
+        assertEquals("/kf-alipay-api/api/alipay/trade/query", lastPath.get(),
+                "正对照:这条 URL 本轮联调已实际打到上游并拿到业务响应。"
+                        + "它若也红,红的是台子(没记到出站路径),不是被测代码。实际收到 = " + lastPath.get());
+        assertEquals("kf.alipay.trade.query", method());
+    }
+
+    @Test
+    void createShop_hitsTheMerchantShopPath() {
+        HarShopCreateRequest req = new HarShopCreateRequest();
+        req.setMerchantCode("M001");
+        req.setThirdShopId("SHOP-TEST-001");
+        req.setName("联调测试门店");
+
+        new HarClient(props).createShop(req);
+
+        assertEquals("/kf-alipay-api/api/merchant/shop/create", lastPath.get(),
+                "上游 2026-09-24 澄清:门店创建的 url 是 /api/merchant/shop/create,"
+                        + "规范 docx 表 1 的 URL 列写错了(写的是 /api/shop/create)。"
+                        + "实际收到 = " + lastPath.get());
+        assertEquals("kf.har.shop.create", method(),
+                "method 取值上游没有更正过。若联调回「method 不存在」,改这一行(同构推测是 kf.merchant.shop.create),"
+                        + "别把 path 改回去。");
+    }
+
+    private String method() {
+        Map<String, String> form = lastForm.get();
+        assertNotNull(form, "台子没收到任何表单 —— 出站请求根本没发出去");
+        return form.get("method");
+    }
+
+    private void handle(HttpExchange ex) throws IOException {
+        lastPath.set(ex.getRequestURI().getPath());
+        lastForm.set(parseForm(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
+
+        byte[] body = "response_code=10000&msg=ok".getBytes(StandardCharsets.UTF_8);
+        ex.getResponseHeaders().add("Content-Type", "text/plain; charset=UTF-8");
+        ex.sendResponseHeaders(200, body.length);
+        ex.getResponseBody().write(body);
+        ex.close();
+    }
+
+    private static Map<String, String> parseForm(String body) {
+        Map<String, String> form = new LinkedHashMap<>();
+        for (String pair : body.split("&")) {
+            if (pair.isEmpty()) {
+                continue;
+            }
+            int idx = pair.indexOf('=');
+            String k = idx < 0 ? pair : pair.substring(0, idx);
+            String v = idx < 0 ? "" : URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
+            form.put(k, v);
+        }
+        return form;
+    }
+
+    private static String generatePkcs8Base64() {
+        try {
+            KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
+            gen.initialize(2048);
+            return Base64.getEncoder().encodeToString(gen.generateKeyPair().getPrivate().getEncoded());
+        } catch (Exception e) {
+            throw new IllegalStateException("生成测试私钥失败", e);
+        }
+    }
+}