Browse Source

feat(进件): LeshouyingClient 补 multipart 出站腿 - 文件不参与签名

新增 postMultipart(path, formFields, fileFieldName, fileName, fileBytes):表单字符串字段补
inst_no / sign 后签名,文件另作一个带文件名的文件部件,末行走 parseApi(/api/* 信封)。
上游文档页 196236572 规定 img_file 不参与签名,故签名 map 里只有字符串字段。

请求侧这次真的分叉(表单部件代替 JSON body),落点是把它拆成三段共用件:signedPayload、
traceOutbound、postForRawEntity。发送腿只认 HttpEntity、不认 payload,因此「4xx/5xx 响应体
不得随异常外流」那道防线连同脱敏口径仍只有一份(复核:postForEntity 1 处、四分支各 1 处、
maskByKey 1 处、mask 1 处)。

文件部件用 ByteArrayResource 匿名子类覆写 getFilename():FormHttpMessageConverter 判
「是不是文件部件」的唯一依据就是 part instanceof Resource ? getFilename() : null
(Spring 6.1.6 字节码),而默认实现返回 null ⇒ 线上发不出 filename 段。

顺带订正 LeshouyingSigner 里已失效的日志指针(postForRawJson → traceOutbound),
约束本身未变。

验证:LeshouyingClientMultipartTest 5 条全绿(brief 要的 4 条 + 1 条线上字节级);
全量 436 通过(PaymentApplicationContextTest 实跑 7.3s,隧道在线)。
两向变异:M-1 红(签名构件里多了 img_file)、M-2 红(filename 变 null)、
M-3 无害变异仍绿,且探针证实变异体确实执行(部件顺序实测改变)。
alphaH 3 weeks ago
parent
commit
0fff307a47

+ 146 - 16
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java

@@ -4,11 +4,15 @@ import com.payment.platform.module.payment.leshouying.config.LeshouyingHttpConfi
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.Resource;
 import org.springframework.http.HttpEntity;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
 import org.springframework.web.client.RestClientResponseException;
 import org.springframework.web.client.RestTemplate;
 
@@ -113,32 +117,152 @@ public class LeshouyingClient {
     }
 
     /**
-     * 两条腿共用的出站链路:拼 payload(剔 null + 补 inst_no + 签名)→ 出站 trace 日志 →
-     * POST → 四分支异常包装 → **返回未经解析的响应体原文**。
+     * 上传图片(multipart/form-data)的进件出站腿 —— 对应上游文档页 196236572。
+     *
+     * <p>上游契约(逐字):表单参数 {@code img_file}(File, 必填) / {@code inst_no}(String, 必填) /
+     * {@code img_name}(String, 必填, 图片的完整名称,带后缀) / {@code sign}(String, 必填);
+     * 返回 {@code result_code} / {@code result_msg} / {@code img_url}。信封属于 /api/* 那一套,
+     * 故末尾用 {@link LeshouyingResponseParser#parseApi} —— 与 {@link #postApi} 是同一条腿,
+     * 只是请求侧的编码方式不同(表单部件而非 JSON body)。
+     *
+     * <p>【为什么文件不参与签名 —— 这是**上游规定**,不是我们的选择】
+     * 文档页 196236572 在 {@code img_file} 一行**明写「不参与签名」**。
+     * 所以签名原文只由 {@code formFields}(补上 {@code inst_no} 与 {@code sign})构成,
+     * 文件部件既不进签名 map、也不进 trace 日志。把文件塞进签名不是「签得更严」而是**签错**:
+     * 上游按它自己那份参数集重算,必然对不上,而失败只会表现为一句笼统的「签名错误」。
+     * 本方法不开任何旁路:签名仍走 {@link LeshouyingSigner},与另外两条腿同一套规则。
+     *
+     * <p>★【已知残留:大文件没有写超时 —— 本方法**没有**解决它,也不许被写成已解决】
+     * {@link LeshouyingHttpConfig} 那组超时防的是 F-1「对端握手后不回、把线程占住」,
+     * 但它挡不住**上传方向**的挂起:{@code SimpleClientHttpRequestFactory} 只设了
+     * connect / read 两个超时,而 {@code readTimeout} 约束的是「请求体**发完之后**等响应」那一段;
+     * {@code HttpURLConnection} **没有写超时**(JDK 未提供 setWriteTimeout),
+     * 对端只收不读、把 TCP 接收窗口收满时,写请求体的线程会一直挂住 ——
+     * 后果与 F-1 相同,而本通道多个调用点跑在 {@code @Scheduled} 线程上
+     * ({@code spring.task.scheduling.pool.size} 默认 1,一个挂住的上传足以饿死整族定时任务)。
+     * 当前唯一的缓解手段是**调用方对文件大小的上限**(Task 6 定 2 MiB),
+     * 那是把风险窗口调小,**不是**超时,更**不是**修复。
+     *
+     * @param path          以 / 开头的路径
+     * @param formFields    表单里的**字符串**字段(不含 inst_no / sign,本方法自动补);
+     *                      文件**不**走这里
+     * @param fileFieldName 文件部件的字段名(上游契约里是 {@code img_file})
+     * @param fileName      文件部件的文件名(multipart 的 filename),不是表单字段
+     * @param fileBytes     文件内容
+     */
+    public Map<String, Object> postMultipart(String path,
+                                             Map<String, String> formFields,
+                                             String fileFieldName,
+                                             String fileName,
+                                             byte[] fileBytes) {
+        return parser.parseApi(
+                postForRawMultipart(path, formFields, fileFieldName, fileName, fileBytes));
+    }
+
+    /**
+     * multipart 腿的请求侧拼装:**只**对字符串字段补 inst_no / sign,文件另作一个带文件名的文件部件。
+     *
+     * <p>部件顺序与签名无关:签名原文由 {@link LeshouyingSigner} 内部按字典序重排,
+     * 这里放进 body 的顺序只决定线上字节的顺序。
+     */
+    private String postForRawMultipart(String path, Map<String, String> formFields,
+                                       String fileFieldName, String fileName, byte[] fileBytes) {
+        // ★ 签名 map 里**只有** formFields + inst_no + sign —— 文件不在其中。
+        //   上游规定(见 postMultipart 的 javadoc 第 2 段),不是这里的选择。
+        Map<String, Object> payload = signedPayload(formFields);
+        traceOutbound(path, payload);
+
+        MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
+        payload.forEach(body::add);
+        body.add(fileFieldName, filePart(fileName, fileBytes));
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+        return postForRawEntity(path, new HttpEntity<>(body, headers));
+    }
+
+    /**
+     * 文件部件。
+     *
+     * <p>★ 必须是 {@link Resource},且**必须**覆写 {@link Resource#getFilename()}。
+     * 判据(Spring 6.1.6 字节码实测,非推测):{@code FormHttpMessageConverter} 判断
+     * 「这个部件是不是文件」的唯一依据就是
+     * {@code getFilename(Object part) = part instanceof Resource ? part.getFilename() : null},
+     * 拿到 null 就不往 Content-Disposition 里写 filename 段。
+     * 而 {@link ByteArrayResource} 的默认实现**返回 null** ⇒ 不覆写就发不出文件名,
+     * 上游收到的是一个没有文件名的普通部件。
+     *
+     * <p>实测线上字节(装置:真实 {@code RestTemplate} + 捕获请求体的
+     * {@code ClientHttpRequestFactory},与 {@code LeshouyingClientMultipartTest} 的
+     * wireFormat 用例同一套):覆写后该部件为
+     * {@code Content-Disposition: form-data; name="img_file"; filename="xxx.jpg"},
+     * 且 {@code Content-Type: image/jpeg}(由 {@code ResourceHttpMessageConverter} 按扩展名推断);
+     * **去掉覆写后** filename 段消失、Content-Type 同时退化成 {@code application/octet-stream}
+     * ——两个读数都是实测,后一个取自 M-2 变异运行时测试打印出来的失败报文。
+     */
+    private Resource filePart(String fileName, byte[] fileBytes) {
+        return new ByteArrayResource(fileBytes) {
+            @Override
+            public String getFilename() {
+                return fileName;
+            }
+        };
+    }
+
+    /**
+     * 两条 JSON 腿(/v3/* 与 /api/*)共用的出站链路:拼 payload(剔 null + 补 inst_no + 签名)
+     * → 出站 trace 日志 → POST → 四分支异常包装 → **返回未经解析的响应体原文**。
      *
      * <p>它刻意**不解析**:解析就是对两套信封做选择的地方,那是 {@link #post} 与
      * {@link #postApi} 唯一各写各的一行。把解析放进这里就必然要在这里再分一次支,
      * 等于把刚拆开的选择题换个地方合回去。
      *
-     * <p>也刻意**不合并**两处的差异:本方法对 `/v3/*` 与 `/api/*` 一视同仁(上游两套信封的
-     * 请求侧确实同构)。若将来某一套的请求侧出现分叉(例如进件要额外带 app_id),
-     * 正确做法是在这里显式加形参,而不是把整段抄一份 —— 抄一份就等于把这段
-     * 「4xx/5xx 响应体不得随异常外流」的安全防线也复制成两份,将来只加固其中一份。
+     * <p>【请求侧真的分叉之后,落点在哪里】multipart 就是一次请求侧分叉。落点**既不是**
+     * 「在本方法上加一个可选形参」,**也不是**「加一个 helper 让本方法调用」,
+     * 而是把原来的单一方法拆成三段**共用件**:{@link #signedPayload}(补 inst_no + 签名)、
+     * {@link #traceOutbound}(脱敏 trace 日志)、{@link #postForRawEntity}(发送 + 四分支包装)
+     * —— 三条腿各自只负责「把自己那一种 entity 拼出来」。
+     * 这是 ruling 明确留出的口子(「若你找到更干净的分法,可以采纳,但要在报告里说明」),
+     * 而它比加形参更硬地满足 ruling 的**目的**:「4xx/5xx 响应体不得随异常外流」那道防线
+     * 连同脱敏口径,**物理上只有一份**,不存在「将来只加固其中一份」的机会。
      */
     private String postForRawJson(String path, Map<String, Object> bizParams) {
+        Map<String, Object> payload = signedPayload(bizParams);
+        traceOutbound(path, payload);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        return postForRawEntity(path, new HttpEntity<>(payload, headers));
+    }
+
+    /**
+     * 三条腿共用的参数准备:剔 null → 补 inst_no → 签名。
+     *
+     * <p>剔 null 与签名规则同源({@link LeshouyingSigner} 的「null 不参与」):
+     * 一个值若被剔出签名原文却仍出现在 multipart 表单里,上游算出的签名就与我们不同。
+     */
+    private Map<String, Object> signedPayload(Map<String, ?> bizParams) {
         Map<String, Object> payload = new LinkedHashMap<>();
         if (bizParams != null) {
             // null 值不上送(与签名规则一致,避免产生歧义)
-            bizParams.forEach((k, v) -> {
-                if (v != null) {
-                    payload.put(k, v);
+            for (Map.Entry<String, ?> e : bizParams.entrySet()) {
+                if (e.getValue() != null) {
+                    payload.put(e.getKey(), e.getValue());
                 }
-            });
+            }
         }
         payload.put("inst_no", props.getInstNo());
         payload.put("sign", signer.sign(payload, props.getKey()));
+        return payload;
+    }
 
-        // ★ 全模块唯一一处记录「签名原文」的地方,必须留在出站侧。
+    /**
+     * 三条腿共用的出站 trace 日志。
+     *
+     * <p>★ 全模块唯一一处记录「签名原文」的地方,必须留在出站侧 —— 理由见方法里下面那段注释
+     * (它同时解释了为什么这里用 {@code maskByKey} 而不是 {@code mask},那条判据不许改)。
+     */
+    private void traceOutbound(String path, Map<String, Object> signedPayload) {
         //   入站路径(LeshouyingSigner.verify → sign)的参数由**攻击者控制**,
         //   在那里打日志会同时带来两个问题:参数值里塞 CRLF 可伪造日志行(CWE-117),
         //   以及 auth_code 被原样落盘。所以 LeshouyingSigner 里一个日志都没有。
@@ -154,16 +278,22 @@ public class LeshouyingClient {
         //   渲染成字符串再过一遍,是为了出站与入站共用**同一份**正则实现,而不是各写一份。
         if (log.isTraceEnabled()) {
             log.trace("乐收赢请求 {} 出站参数(已脱敏): {}",
-                    path, LeshouyingSensitive.maskByKey(payload.toString()));
+                    path, LeshouyingSensitive.maskByKey(signedPayload.toString()));
         }
+    }
 
+    /**
+     * 三条腿共用的发送腿:把已拼好的 entity POST 出去 → 四分支异常包装 →
+     * **返回未经解析的响应体原文**。
+     *
+     * <p>它**只认 entity,不认 payload** —— 这正是 multipart 那次请求侧分叉能落地、
+     * 而不用把下面这四段 catch 抄一份的原因(见 {@link #postForRawJson} 的 javadoc)。
+     * 解析同样不在这里:解析是「选哪套信封」,住在各条腿自己的末行。
+     */
+    private String postForRawEntity(String path, HttpEntity<?> entity) {
         String url = props.getBaseUrl() + path;
         String json;
         try {
-            HttpHeaders headers = new HttpHeaders();
-            headers.setContentType(MediaType.APPLICATION_JSON);
-            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);
-
             long start = System.currentTimeMillis();
             ResponseEntity<String> resp = restTemplate.postForEntity(url, entity, String.class);
             long cost = System.currentTimeMillis() - start;

+ 6 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSigner.java

@@ -54,6 +54,12 @@ public class LeshouyingSigner {
      * (2026-09-22 订正:此处原写 `post()`。抽出 `/api/*` 出站腿后 `post()` 只剩一行委托,
      *   日志实际住在 `postForRawJson`,由 `/v3/*` 与 `/api/*` **两条腿共用** ——
      *   即两条腿共用同一份脱敏口径,改动它会影响两边。)见 Task 4。
+     * (2026-09-22 再订正:加入 multipart 图片上传腿时,脱敏 trace 日志已抽成
+     *   `LeshouyingClient` 的私有方法 `traceOutbound()`,由 `/v3/*`、`/api/*`、上传
+     *   **三条腿共用**;`postForRawJson` 现在只负责拼 JSON entity。
+     *   上面那句「放 postForRawJson」里的**方法名**已过时,**约束本身不变**:
+     *   日志仍在出站侧、参数仍由我们自己构造、仍只按键名脱敏(`maskByKey`)。
+     *   要点名方法时请以 `traceOutbound` 为准。)见 Task 2。
      */
     public String sign(Map<String, Object> params, String key) {
         Map<String, Object> clean = new LinkedHashMap<>();

+ 260 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientMultipartTest.java

@@ -0,0 +1,260 @@
+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.junit.jupiter.MockitoExtension;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.mock.http.client.MockClientHttpRequest;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.nio.charset.StandardCharsets;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+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.postMultipart` —— 图片上传的 multipart 出站腿(上游文档页 196236572)。
+ *
+ * 【为什么值得单开一个测试类】它是**唯一**一条带文件部件的出站腿,也是唯一一条请求侧
+ * 与另两条腿分叉的腿:表单部件代替 JSON body,且上游明写 `img_file` **不参与签名**。
+ * 签名口径一旦在这条腿上走偏,失败形态是上游回一句笼统的「签名错误」——
+ * 排查方向会指向密钥而不是参数集。所以这里逐条钉住:签名的**构件**、文件部件的**文件名**、
+ * 请求的 **Content-Type**、以及末行的**信封**,最后再用一条线上字节级用例兜底。
+ *
+ * 【测试里没有、也不该有任何真实凭据】key 是占位串(签名器对 key 只做拼接、不校验形状),
+ * 所以「确实签了、且签的是 props 里那个 key」照样可钉,而机构密钥不必被抄进测试文件。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingClientMultipartTest {
+
+    private static final String BASE_URL = "https://test.gdyfsk.com/yfpay";
+
+    /**
+     * 路径只是桩用的占位串,**不代表上游真实接口名** —— 真实路径由文档给出,
+     * 本任务(客户端出站腿)不该把它固化在这里,否则它会变成第二个真相来源。
+     */
+    private static final String UPLOAD_PATH = "/api/uploadImg";
+
+    private static final String FILE_FIELD = "img_file";
+
+    /**
+     * ★ `img_name` 与 `fileName` **故意取不同的值**。
+     * 契约上这是两样东西:`img_name` 是上游要求的**表单字段**(图片的完整名称,带后缀),
+     * `fileName` 是 multipart 的文件名。只有把两者分开,「文件的名字不是签名构件」这句
+     * 才**可断言**(若两者用同一个字符串,`img_name` 会让那条断言必然为假 —— 见下面的注释)。
+     */
+    private static final String IMG_NAME = "business-licence.jpg";
+    private static final String FILE_NAME = "upload-20260922.jpg";
+
+    /** 文件内容用可检索的 ASCII 标记,才能断言「这段字节没有被当成签名构件」。 */
+    private static final String FILE_MARKER = "FAKE-JPEG-BODY-MUST-NOT-BE-SIGNED";
+    private static final byte[] FILE_BYTES = FILE_MARKER.getBytes(StandardCharsets.UTF_8);
+
+    private static final String OK_BODY =
+            "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"http://x/y.jpg\"}";
+
+    @Mock private RestTemplate restTemplate;
+    private LeshouyingProperties props;
+    private LeshouyingClient client;
+
+    @BeforeEach
+    void setUp() {
+        props = new LeshouyingProperties();
+        props.setBaseUrl(BASE_URL);
+        props.setInstNo("900112");
+        // ★ 占位密钥,**故意不是**任何真实凭据(全局约束:凭据只打印长度、不落值)。
+        props.setKey("task-2-placeholder-key-not-a-credential");
+        client = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
+                new LeshouyingResponseParser(), props);
+    }
+
+    /**
+     * ★ 这条钉的是**签名原文的构件集合**:只有字符串字段,没有文件。
+     *
+     * 上游规定 `img_file` 不参与签名,所以客户端必须做到两件事,它们是**两个不同的失败模式**,
+     * 由两组断言分别接住:
+     * <ol>
+     *   <li>**签名算错**(把文件的内容/名字也拿去签名,但没当成表单字段发出去):
+     *       发出的 `sign` 就与「收到的那些字符串字段」对不上 ⇒ 断言 ① 红。</li>
+     *   <li>**多发了字段**(把文件当成一个字符串部件塞进签名 map 与表单):
+     *       `sign` 自洽(上游按同一份参数集重算也会得到同一个值),
+     *       所以只靠 ① **接不住** ⇒ 必须有断言 ②(表单里不许有 `img_file` 这个字符串部件)。</li>
+     * </ol>
+     * 第二个失败模式正是「把文件部件也塞进签名 map」这种改法的产物,也就是 M-1 那一次变异。
+     */
+    @Test
+    void postMultipart_signsOnlyFormFields_fileIsNotSigned() {
+        stubOk(OK_BODY);
+
+        client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        Map<String, Object> stringParts = stringPartsOf(capturedBody());
+
+        assertEquals("900112", stringParts.get("inst_no"), "inst_no 必须自动补进签名原文");
+        assertNotNull(stringParts.get("sign"), "sign 必须自动补进签名原文");
+
+        // ① 签名必须能用 props 里那个 key 验回 —— 即「签的确实是这几个字符串部件」
+        assertTrue(new LeshouyingSigner().verify(stringParts, props.getKey()),
+                "字符串部件重算的签名不成立;实际部件名: " + stringParts.keySet());
+
+        // ② 文件**不得**作为字符串部件进入签名原文(M-1 的落点)
+        assertFalse(stringParts.containsKey(FILE_FIELD),
+                "文件部件不得进入签名 map:上游规定 img_file 不参与签名;实际部件名: "
+                        + stringParts.keySet());
+        // ③ 文件的两个可识别特征都不该是签名原文里的值
+        assertFalse(stringParts.containsValue(FILE_NAME),
+                "multipart 的文件名不得出现在签名原文里;实际: " + stringParts);
+        assertTrue(stringParts.values().stream()
+                        .noneMatch(v -> String.valueOf(v).contains(FILE_MARKER)),
+                "文件内容不得出现在签名原文里;实际: " + stringParts);
+    }
+
+    /**
+     * ★ 钉住那个**匿名子类**:默认的 `ByteArrayResource.getFilename()` 返回 null,
+     * 不覆写就拿不到文件名 —— 而 `FormHttpMessageConverter` 判「这是不是文件部件」的唯一依据
+     * 就是 `part instanceof Resource ? part.getFilename() : null`(Spring 6.1.6 字节码)。
+     *
+     * <p>这条是**代理指标**(量的是我们交给框架的那个对象);真正决定上游收到什么的是
+     * {@link #postMultipart_wireFormat_filePartCarriesFilename()} 那条线上字节级用例。
+     * 两条都要:这一条在文件部件被换成别的类型时最先红,那一条在字节层面兜底。
+     */
+    @Test
+    void postMultipart_sendsFilePartWithRealFilename() {
+        stubOk(OK_BODY);
+
+        client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        Object part = capturedBody().getFirst(FILE_FIELD);
+
+        assertInstanceOf(Resource.class, part,
+                "文件部件必须是 Resource(字符串部件拿不到文件名),实际: "
+                        + (part == null ? "null" : part.getClass().getName()));
+        assertEquals(FILE_NAME, ((Resource) part).getFilename(),
+                "文件名没传到文件部件上 —— 默认 ByteArrayResource 返回 null");
+    }
+
+    /** 请求必须是 multipart;写成 JSON 或 form-urlencoded 时上游收不到文件部件。 */
+    @Test
+    void postMultipart_contentTypeIsMultipartFormData() {
+        stubOk(OK_BODY);
+
+        client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        MediaType contentType = capturedEntity().getHeaders().getContentType();
+        assertNotNull(contentType, "请求必须带 Content-Type");
+        // 断言 startsWith 而不是 equals:真正的请求头由转换器补 boundary,我们只定类型。
+        assertTrue(contentType.toString().startsWith("multipart/form-data"),
+                "文件上传必须走 multipart,实际: " + contentType);
+    }
+
+    /**
+     * 信封必须是 `/api/*` 那一套。
+     *
+     * 桩回的报文里**只有** `result_code`(进件信封的形状),没有 `return_code`:
+     * 走 `parseApi` ⇒ 正常返回;误走 `parseV3` ⇒ 抛 PARSE_ERROR
+     * (`parseV3` 第一条分支就是「缺 return_code ⇒ 非 v3 响应」)。两种实现在返回值上直接可见。
+     */
+    @Test
+    void postMultipart_parsesApiEnvelope() {
+        stubOk(OK_BODY);
+
+        Map<String, Object> resp = client.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        assertEquals("SUCCESS", resp.get("result_code"));
+        assertEquals("http://x/y.jpg", resp.get("img_url"),
+                "img_url 是这条接口唯一的产出,取不到就等于上传白做");
+    }
+
+    /**
+     * ★★ 线上字节级用例:本类唯一能证明「上游确实会收到一个**带 filename 的文件部件**」的断言。
+     *
+     * <p>上面那条 `sendsFilePartWithRealFilename` 量的是「我们交给框架的 Resource 上
+     * getFilename() 不为 null」—— 那是代理指标,中间还隔着转换器。这里换成**真实的
+     * `RestTemplate`**(默认转换器列表,与生产的 `lsyRestTemplate` 只差请求工厂)
+     * 接在一个捕获请求体的 `ClientHttpRequestFactory` 上,直接读它写出来的 multipart 字节。
+     *
+     * <p>顺带钉住两件只有字节层面才看得见的事:字符串字段**不带** filename 段
+     * (带了就不再是普通表单字段)、文件内容真的进了报文(不是只发了个空壳部件)。
+     */
+    @Test
+    void postMultipart_wireFormat_filePartCarriesFilename() {
+        AtomicReference<MockClientHttpRequest> captured = new AtomicReference<>();
+        ClientHttpRequestFactory capturingFactory = (uri, method) -> {
+            MockClientHttpRequest req = new MockClientHttpRequest(method, uri);
+            req.setResponse(new MockClientHttpResponse(
+                    OK_BODY.getBytes(StandardCharsets.UTF_8), HttpStatus.OK));
+            captured.set(req);
+            return req;
+        };
+        LeshouyingClient wireClient = new LeshouyingClient(new RestTemplate(capturingFactory),
+                new LeshouyingSigner(), new LeshouyingResponseParser(), props);
+
+        wireClient.postMultipart(UPLOAD_PATH, Map.of("img_name", IMG_NAME),
+                FILE_FIELD, FILE_NAME, FILE_BYTES);
+
+        String wire = captured.get().getBodyAsString(StandardCharsets.UTF_8);
+
+        assertTrue(wire.contains("Content-Disposition: form-data; name=\"" + FILE_FIELD
+                        + "\"; filename=\"" + FILE_NAME + "\""),
+                "文件部件的 Content-Disposition 必须带 filename,实际报文:\n" + wire);
+        assertTrue(wire.contains("name=\"img_name\""), "字符串字段必须在报文里,实际报文:\n" + wire);
+        assertFalse(wire.contains("name=\"img_name\"; filename="),
+                "字符串字段被写成了文件部件(不该有 filename 段),实际报文:\n" + wire);
+        assertTrue(wire.contains(FILE_MARKER), "文件内容必须真的写进报文,实际报文:\n" + wire);
+    }
+
+    private void stubOk(String body) {
+        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
+                .thenReturn(new ResponseEntity<>(body, HttpStatus.OK));
+    }
+
+    private HttpEntity<?> capturedEntity() {
+        ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
+        verify(restTemplate).postForEntity(
+                eq(BASE_URL + UPLOAD_PATH), captor.capture(), eq(String.class));
+        return captor.getValue();
+    }
+
+    @SuppressWarnings("unchecked")
+    private MultiValueMap<String, Object> capturedBody() {
+        return (MultiValueMap<String, Object>) capturedEntity().getBody();
+    }
+
+    /** 只挑字符串部件 —— 与出站侧「签名原文只由字符串字段构成」同一口径。 */
+    private Map<String, Object> stringPartsOf(MultiValueMap<String, Object> body) {
+        Map<String, Object> stringParts = new LinkedHashMap<>();
+        body.forEach((name, values) -> {
+            for (Object v : values) {
+                if (v instanceof String s) {
+                    stringParts.put(name, s);
+                }
+            }
+        });
+        return stringParts;
+    }
+}