Bläddra i källkod

feat(进件): 图片上传 - 本地大小与类型护栏在出站之前

上游 page 196236572:路径 /api/info/upload/img,字符串部件 img_name + 文件部件
img_file(文档明写文件不参与签名),返回 img_url。复用 Task 2 的 postMultipart,
本 task 只做上游 leg:不加 HTTP 端点、不加菜单、不写 SQL。

LeshouyingProperties 加 uploadMaxBytes,默认 2 MiB,裸字段初始化,不进 validate()。
这是**我们**的护栏、不是上游限制:上游文档没写大小上限,它的用途是把
postMultipart 那条「大文件没有写超时」的已知残留(HttpURLConnection 没有写超时,
上传线程挂住会饿死 @Scheduled)的风险窗口调小,不是修复。

LsyOnboardingService.uploadImage(byte[], String):六道本地护栏全部在
postMultipart 调用之前,一个字节不出海。
  内容非空 / 大小闭区间 / 文件名非空白 / 无 ISO 控制字符 / 无路径分隔符或 .. /
  扩展名白名单 jpg jpeg png bmp(不区分大小写)
其中「内容非空」「文件名非空白」的上游对应物是参数表的必填列,但「空白算不算填了」
是我们的判据;其余四道全是我们加的,不写成上游要求。
img_name 与文件部件 filename 同源(同一个形参),这是我们的收窄,文档没有规定
二者必须一致。img_url 缺失或空白抛 502,不返回 null,与 createShop 缺 shop_no 同源;
文案不含上游名号(Global Constraint 2),这些串将来会经三-B 的端点给到上传方。

postMultipart 的入口守卫抛的是 IllegalArgumentException(程序员错误语义,
且不会被包成 LeshouyingApiException),所以本层必须自己拦成 BusinessException(400):
两层都要,异常类型不同、理由不同。

LeshouyingProperties 被注入本服务,构造函数从 1 参变 2 参;全仓唯一调用点
(测试 setUp,落笔前 grep 复量为 1 处)随之改,已有 32 条用例一条未动。

新增 13 条用例,测试类 32 -> 45:
- 四条「不该出海」的用例(超限 / null bytes / 空文件名含全空白 / 控制字符)
  都以 verifyNoInteractions(client) 收尾,且一次都不打桩
  (打桩不算交互,但打了桩没人用会在 afterEach 以 UnnecessaryStubbingException 变红)
- uploadImage_ordinaryFilename_accepted 是正对照,给控制字符锁验宽度
- uploadImage_exactlyAtLimit_accepted 钉死闭区间(> 不是 >=)
- uploadImage_longFileName_notTruncated 是「不做」的钉子:不擅自截断长文件名
  (按 R-T6-6 改名,brief 原名 ...LengthCapped 描述的是变异体而不是要求)
- uploadImage_nullBytes_rejected 按 R-T6-3 补上(原 brief 表漏了这条输入)
测试的桩一律喂真实 LeshouyingResponseParser 解析出来的 map,不手搓 Map。

同一提交里 LeshouyingPropertiesTest 加 1 条(6 -> 7):
validate_uploadMaxBytes_isNotInRequiredList,带正对照(必填四项齐备 + 默认值时不抛),
非正值 0 / -1 也不拦(R-T6-4)。uploadMaxBytes 取默认值不在服务测试里覆盖(R-T6-2),
2 MiB 这个数字由闭区间与超一字节两条用例同时钉住。

变异(每条都先删掉目标 class 强制重编,class 指纹逐条核对变化;两向读数见报告):
M-1a/M-1b 大小校验挪到调用之后      -> uploadImage_oversize_rejectedLocally 红
M-2  边界改成 >=                    -> uploadImage_exactlyAtLimit_accepted 红
M-3  扩展名比较不做 toLowerCase     -> uploadImage_disallowedExtension_rejected 红
M-4  路径分隔符改成清洗后照发       -> uploadImage_fileNameWithPathSeparator_rejected 红
M-5  给文件名加 substring(0, 50)    -> uploadImage_longFileName_notTruncated 红
M-6  白名单 Set.of 换成 List.of     -> 仍绿(无害改动,实测)
M-7  去掉控制字符校验               -> uploadImage_controlCharInFileName_rejected 红
M-8  控制字符校验写成过宽           -> uploadImage_ordinaryFilename_accepted 红
M-9  null/空白检查挪到扩展名之后    -> uploadImage_nullOrBlankFileName_rejected 红(NPE)

测试:483 -> 497,0 fail / 0 error / 0 skipped,BUILD SUCCESS
alphaH 3 veckor sedan
förälder
incheckning
b229688632

+ 23 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java

@@ -77,6 +77,29 @@ public class LeshouyingProperties {
     /** 下游回调最大重试次数 */
     private int downstreamNotifyMaxRetries = 8;
 
+    /**
+     * 上传图片的**本地**大小上限(字节),默认 2 MiB。
+     *
+     * <p>★ <b>这是我们自己的护栏,不是上游限制。</b>上游文档(page 196236572)**没有**写
+     * {@code img_file} 的大小上限,所以这个数字不能读成「上游最多能收多大」。它的用途见
+     * {@code LeshouyingClient#postMultipart} javadoc 末尾那段「已知残留:大文件没有写超时」:
+     * {@code HttpURLConnection} 没有写超时,对端只收不读、把 TCP 接收窗口收满时,写请求体的
+     * 线程会一直挂住;而本模块多个调用点跑在 {@code @Scheduled} 线程上
+     * (调度池默认 1)⇒ 一个挂住的上传足以饿死整族定时任务。
+     * <b>上限只是把风险窗口调小,不是超时,更不是修复。</b>
+     *
+     * <p>★ <b>改大之前先算堆占用。</b>图片在本通道是**整块 {@code byte[]} 进内存**的
+     * (三-B 从 {@code MultipartFile.getBytes()} 取,本层原样交给客户端),
+     * 并发上传时峰值 ≈ 上限 × 并发数,而 multipart 解析阶段还会另占一份。
+     * 调到 20 MiB 不是「允许传大图」,而是「允许 N 个并发把 N×20 MiB 一起压进堆」。
+     *
+     * <p>不进 {@code validate()}:它带合理默认值,键名打错也只是退回默认值,
+     * 不属于 {@code validate()} javadoc 所说的「缺了就静默失败」那一类
+     * (与上面那三个轮询/过期旋钮同理)。非正值也**不拦** —— 那会让所有上传 400,
+     * 是**吵闹的**失败,第一次上传就会发现,不值得占一个启动期必填位。
+     */
+    private long uploadMaxBytes = 2L * 1024 * 1024;
+
     /**
      * 启动即校验,缺配置就大声失败。
      *

+ 164 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
 import cn.hutool.json.JSONNull;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
@@ -17,7 +18,9 @@ import org.springframework.stereotype.Service;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * 进件(商户入网)服务。上游走 {@code /api/*} 信封 —— 与支付类 {@code /v3/*} 不是同一套
@@ -77,8 +80,34 @@ public class LsyOnboardingService {
     /** 门店管理员**手机号**的键名。拼写冲突与 {@link #KEY_MANAGER_NAME} 完全同源(示例写 {@code maneger_phone})。 */
     private static final String KEY_MANAGER_PHONE = "manager_phone";
 
+    /**
+     * 上传图片的路径(上游 page 196236572)。文档原写
+     * {@code https://test.gdyfsk.com/yfpay/api/info/upload/img},**基址由
+     * {@link LeshouyingProperties#getBaseUrl()} 提供**,这里只给路径段 ——
+     * 与另外三条腿({@code post} / {@code postApi} / {@code postMultipart})的既有约定一致。
+     */
+    private static final String PATH_UPLOAD_IMG = "/api/info/upload/img";
+
+    /** 文件部件的字段名。上游参数表:{@code img_file}(File, 必填),且**明写「不参与签名」**。 */
+    private static final String FILE_FIELD_IMG = "img_file";
+
+    /** 文件名字符串部件的键名。上游参数表:{@code img_name}(String, 必填, 图片的完整名称,带后缀)。 */
+    private static final String KEY_IMG_NAME = "img_name";
+
+    /**
+     * 允许的图片扩展名(**全小写**,比较前先把待判值归一)。
+     *
+     * <p>★ 这是**我们收窄**的白名单,不是上游列举的:上游文档没有写它接受哪些扩展名。
+     * 依据只有两条 —— 上游这些字段全是「照片」语义({@code picture_*} 十五处、
+     * {@code img_file} / {@code img_name}),且文档给的示例是 {@code .jpg}。
+     * 将来真机联调撞上被误拒的合法后缀(例如 {@code .webp}),改的是**这一行**。
+     */
+    private static final Set<String> ALLOWED_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "bmp");
+
     private final LeshouyingClient client;
 
+    private final LeshouyingProperties props;
+
     /**
      * 获取省市区,用于三级联动。上游 page 196236568。
      *
@@ -282,6 +311,141 @@ public class LsyOnboardingService {
         return toShopItems(client.postApi("/api/shop/list", mchNoParams(mchNo)));
     }
 
+    /**
+     * 上传图片(上游 page 196236572,路径 {@code /api/info/upload/img})。
+     *
+     * <p>上游入参:{@code img_file}(File, 必填) / {@code img_name}(String, 必填, 图片的完整名称,
+     * 带后缀) / {@code inst_no}(String, 必填) / {@code sign}(String, 必填) —— 后两个由
+     * {@link LeshouyingClient#postMultipart} 补。出参:{@code result_code} / {@code result_msg} /
+     * {@code img_url}。信封属于 {@code /api/*} 那一套 ⇒ 非 {@code SUCCESS} 抛
+     * {@link com.payment.platform.module.payment.leshouying.client.LeshouyingApiException},
+     * **能返回就说明上游认了这次上传**,不存在「返回值里 {@code result_code} 是 FAIL」。
+     *
+     * <p>★ <b>{@code img_file} 不参与签名 —— 这是上游规定、不是我们的选择。</b>
+     * 文档在 {@code img_file} 一行明写「不参与签名」;实现与理由都在
+     * {@link LeshouyingClient#postMultipart} 的 javadoc 里,此处不复述第二份。
+     *
+     * <p>★ <b>{@code img_name} 与文件部件上的 filename 都取自同一个形参 {@code fileName}。</b>
+     * 这是**我们的收窄**:文档把 {@code img_name} 列为必填字符串部件、示例值
+     * ({@code 8510_pic_idcard.jpg})与文件名同形,但**没有**规定二者必须一致;真不一致时
+     * 上游会怎么解读,我们**不知道**(未实测)。收窄的理由只是不给调用方两个可能写出
+     * 不一致值的入口 —— 一旦不一致,我们发出去的是一份自己都推不出上游会怎么读的报文。
+     *
+     * <p><b>【本地护栏:六道,全部在 {@code postMultipart} 调用<u>之前</u>,一个字节不出海】</b>
+     * 逐道见 {@link #requireUploadable}。其中「内容非空」「文件名非空白」两道的上游对应物是
+     * 参数表里的「必填」列,但<b>「空白算不算填了」是我们的判据</b>;其余四道**全是我们加的**。
+     * <b>不要</b>把它们写成「上游要求」。
+     *
+     * <p>【为什么必须在这一层拦,而不是靠客户端入口那串 {@code requireHeaderSafe}】
+     * 客户端拒 {@code null} / 控制字符时抛的是 {@link IllegalArgumentException}
+     * —— <b>程序员错误</b>语义,且**不会**被包成 {@code LeshouyingApiException},
+     * 因此它绕过了本模块的业务异常通道;而这里的 {@code bytes} 与 {@code fileName} 都是
+     * **上传方的输入**(三-B 从 {@code MultipartFile} 取),必须变成
+     * {@link BusinessException}(400, 我们自己的中文文案) —— 它既不该冒充**租户的业务规则**,
+     * 也不该冒充**服务器崩溃**。**两层都要,异常类型不同、理由不同。**
+     *
+     * <p>★ <b>文案不得出现上游名号</b>(Global Constraint 2):这些串将来会经三-B 的端点
+     * 原样给到上传方。所有分支的文案都在 {@link #requireUploadable} 里,改字时守住这一条。
+     *
+     * <p>★ <b>{@code img_url} 缺失或空白 ⇒ {@link BusinessException}(502),不返回 null、不静默。</b>
+     * 理由与 {@link #createShop} 缺 {@code shop_no} 时**完全同源**(那里写得细,不复述):
+     * 调用方拿 null 存库就是一条**查不回来**的记录 —— 图片在上游**确实传上去了**,本地却没有地址;
+     * 空串与「没给」一样不可用,所以判据是 {@code isBlank} 而不是「只判 null」。
+     * 同理:<b>走到这条分支时图片可能已经在上游落库,调用方不得当作「上传失败」直接重传。</b>
+     *
+     * <p>★ <b>本方法不收任何租户标识 —— 上传出来的图片在上游是平台级的,不绑定到任何租户。</b>
+     * 这与类 javadoc 的租户缺口(R-4)是**同一类**前置条件,只是没有 {@code mchNo} 这个抓手:
+     * 本层看着像做过校验(有六道护栏、还抛 400),其实一道都不问「你是谁」。
+     * <b>三-B 的端点必须先决定「谁可以往这条通道传图」</b>,再决定返回的 {@code img_url} 给谁看。
+     * (该地址能不能被他人猜出/遍历,取决于上游怎么生成它 —— **未实测**,不在本层能管的范围。)
+     *
+     * @param bytes    图片内容。{@code null} / 0 字节 ⇒ 400,**不发上游**
+     * @param fileName 文件名,同时用作 {@code img_name} 与文件部件的 filename。
+     *                 空白 / 含 ISO 控制字符 / 含路径分隔符或 {@code ..} / 扩展名不在白名单
+     *                 ⇒ 400,**不发上游**
+     * @return 上游给的 {@code img_url},**保证非空白**
+     * @throws BusinessException 400 本地护栏拒绝(见上);502 上游返回成功但没给可用的 {@code img_url}
+     */
+    public String uploadImage(byte[] bytes, String fileName) {
+        requireUploadable(bytes, fileName);
+
+        Map<String, String> form = new LinkedHashMap<>();
+        form.put(KEY_IMG_NAME, fileName);
+        // 文件**不**进 form:它既不进签名原文,也不是一个「字符串字段」(见 postMultipart 的 javadoc)。
+        String imgUrl = asText(client
+                .postMultipart(PATH_UPLOAD_IMG, form, FILE_FIELD_IMG, fileName, bytes)
+                .get("img_url"));
+        if (StrUtil.isBlank(imgUrl)) {
+            throw new BusinessException(502,
+                    "上传成功但缺少图片地址 img_url(图片可能已上传成功,不得当作失败直接重传)");
+        }
+        return imgUrl;
+    }
+
+    /**
+     * 上传图片的六道**本地**护栏,全部在任何出站之前(连实体都不构造)。
+     *
+     * <p><b>顺序是有意的,不要重排</b>:
+     * <ol>
+     *   <li><b>内容非空</b>({@code null} / 0 字节)。★ {@code null} 若漏到客户端入口,抛的是
+     *       {@link IllegalArgumentException}(程序员错误语义),两条出口都不好 ——
+     *       见 {@link #uploadImage} 里「为什么必须在这一层拦」。<b>我们的护栏</b>,上游文档没提。</li>
+     *   <li><b>大小上限,闭区间</b>:恰好等于上限**放行**({@code >} 不是 {@code >=})。
+     *       上限的来源与理由见 {@link LeshouyingProperties#getUploadMaxBytes()} ——
+     *       <b>是我们的护栏,不是上游限制</b>(上游没写大小上限)。
+     *       ★ 它排在出站<u>之前</u>是这道护栏的全部意义:挪到调用之后,本地照样 400、
+     *       文案一模一样,但**报文已经出海了** —— {@code uploadImage_oversize_rejectedLocally}
+     *       抓的就是这个位置。</li>
+     *   <li><b>文件名非空白</b>({@code null} / 空串 / 全空白)。★ <b>必须排在扩展名检查之前</b>,
+     *       否则 {@code null} 会先在 {@code lastIndexOf('.')} 处炸成 <b>NPE</b> 而不是 400。
+     *       这一层不可省:客户端只拒 {@code null}(且是程序员错误语义),
+     *       <b>空串与全空白不拒</b> —— 它自己的 javadoc 明写那是「上游侧校验的活、是调用方(= 本层)的活」。
+     *       而 {@code null} 是真实可达的:三-B 多半从
+     *       {@code MultipartFile.getOriginalFilename()} 取,**那个 API 的契约就是可空的**。</li>
+     *   <li><b>文件名不含 ISO 控制字符</b>。钉的是 **2026-09-22 实测出的 multipart 部件头注入**
+     *       ({@code fileName} 会被原样写进 {@code Content-Disposition},里面的 CR/LF 会在部件头块里
+     *       造出一行新的头);读数与依据强度逐条写在 {@link LeshouyingClient#postMultipart} 的
+     *       javadoc 第 3 段,**不复述**。与客户端那层重复是**有意**的,理由见 {@link #uploadImage}。
+     *       <b>我们的护栏</b>,上游文档没提。</li>
+     *   <li><b>文件名不含 {@code /}、{@code \}、{@code ..}</b>。是**拒绝**、不是**清洗后照发** ——
+     *       清洗会让日志里的名字与实际发出去的名字不一致:排障时看到的名字不是上游收到的那个,
+     *       那比直接拒更坏。<b>我们的护栏</b>。</li>
+     *   <li><b>扩展名在白名单内</b>(比较前先转小写,故 {@code a.JPG} 通过):
+     *       {@link #ALLOWED_IMAGE_EXTENSIONS}。没有后缀({@code lastIndexOf} 为 -1)⇒ 空串
+     *       ⇒ 不在白名单。<b>我们的收窄</b>。</li>
+     * </ol>
+     *
+     * @throws BusinessException 400,文案为中文且**不含上游名号**(Global Constraint 2)
+     */
+    private void requireUploadable(byte[] bytes, String fileName) {
+        if (bytes == null || bytes.length == 0) {
+            throw new BusinessException(400, "图片内容不能为空");
+        }
+        if (bytes.length > props.getUploadMaxBytes()) {
+            // 两个数字都报出来:只说「超过上限」的话,上传方不知道该压到多少。
+            throw new BusinessException(400, "图片大小超过上限("
+                    + bytes.length + " 字节 > " + props.getUploadMaxBytes() + " 字节)");
+        }
+        if (StrUtil.isBlank(fileName)) {
+            throw new BusinessException(400, "图片文件名不能为空");
+        }
+        for (int i = 0; i < fileName.length(); i++) {
+            if (Character.isISOControl(fileName.charAt(i))) {
+                throw new BusinessException(400, "图片文件名不能含控制字符");
+            }
+        }
+        if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
+            throw new BusinessException(400, "图片文件名不能含路径分隔符");
+        }
+        int dot = fileName.lastIndexOf('.');
+        String ext = dot < 0 ? "" : fileName.substring(dot + 1);
+        // Locale.ROOT:不跟随默认 locale。本白名单四个扩展名都不含字母 i,默认 locale 的
+        // 土耳其-I 问题在这里测不出来,写上只是不让它将来变成一颗雷。
+        if (!ALLOWED_IMAGE_EXTENSIONS.contains(ext.toLowerCase(Locale.ROOT))) {
+            throw new BusinessException(400, "不支持的图片扩展名,仅支持 jpg / jpeg / png / bmp");
+        }
+    }
+
     /**
      * 两个收 {@code mchNo} 的接口共用的出站参数:{@code merchant_no} + 一个**新生成**的
      * {@code trace_no}。

+ 31 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/config/LeshouyingPropertiesTest.java

@@ -95,6 +95,37 @@ class LeshouyingPropertiesTest {
         assertTrue(ex.getMessage().contains("配置缺失"), "实际: " + ex.getMessage());
     }
 
+    // ---- ★ uploadMaxBytes **不**进必填清单 ----
+
+    /**
+     * ★ {@code uploadMaxBytes} **不在** {@code validate()} 的必填清单里 —— 这条**不是**
+     * 在描述现状(现状下它天然成立,因为 {@code validate()} 是手写清单、不是反射,
+     * 加字段不会自动进必填),而是**挡后来者**:谁顺手把新加的开关塞进必填清单,
+     * 就会把「开发期开箱即用」变成「少一个环境变量就起不来」。
+     *
+     * <p>判据是 {@code validate()} 自己 javadoc 里那一条(**缺了就静默失败**):
+     * {@code uploadMaxBytes} 有合理默认值,键名打错也只是退回默认值,不属于那类。
+     *
+     * <p>★ **正对照**:先确证「必填四项齐备 + 默认值」时 {@code validate()} 确实放行 ——
+     * 少了这一句,下面的「不抛」可能只是被别的原因短路了,读数就不成立。
+     * 默认值本身也在这里钉一下,好让「有默认值」这个前提是**可复现**的,而不是靠 javadoc 转述。
+     *
+     * <p>★ 非正值(0 / 负数)**也不拦**(R-T6-4):它会让所有上传 400,是**吵闹的**失败 ——
+     * 第一时间就会发现,不值得占一个启动期必填位。
+     */
+    @Test
+    void validate_uploadMaxBytes_isNotInRequiredList() {
+        LeshouyingProperties p = realProps();
+        p.setActiveProfiles("prod");
+        assertEquals(2L * 1024 * 1024, p.getUploadMaxBytes(), "默认值(brief 定:2 MiB)");
+        assertDoesNotThrow(p::validate, "正对照:必填四项齐备 + 默认值时必须放行");
+
+        for (long bad : new long[]{0L, -1L}) {
+            p.setUploadMaxBytes(bad);
+            assertDoesNotThrow(p::validate, "uploadMaxBytes=" + bad + " 不该被 validate() 拦下");
+        }
+    }
+
     // ---- ★ 防漂移:Java 常量必须与 application.yml 里的默认值一致 ----
 
     @Test

+ 336 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -3,6 +3,7 @@ package com.payment.platform.module.payment.leshouying.service;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
@@ -14,6 +15,7 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.nio.charset.StandardCharsets;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -52,13 +54,29 @@ class LsyOnboardingServiceTest {
     /** 测试商户号。A 阶段手工维护的就是这一个(见 {@code LsyMerchantService} 的类 javadoc)。 */
     private static final String MCH_NO = "Y52211090004";
 
+    // ---- Task 6:图片上传(上游 page 196236572)用的夹具 ----
+
+    /** 一张「图」的字节。上传这条腿**只看长度**,内容是什么与本类要钉的东西无关。 */
+    private static final byte[] IMG_BYTES = "fake-jpeg-bytes".getBytes(StandardCharsets.UTF_8);
+
+    /** 文件名,取上游 {@code img_name} 的示例形态({@code 8510_pic_idcard.jpg})。 */
+    private static final String IMG_NAME = "8510_pic_idcard.jpg";
+
+    /** 上游 {@code img_url} 的示例形态。 */
+    private static final String IMG_URL = "http://www.abc.com/picture/xxx.jpg";
+
     @Mock private LeshouyingClient client;
 
+    private LeshouyingProperties props;
+
     private LsyOnboardingService service;
 
     @BeforeEach
     void setUp() {
-        service = new LsyOnboardingService(client);
+        // ★ 不 setter 覆盖任何字段:uploadMaxBytes 取默认值 2 MiB(R-T6-2),
+        //   让这个数字被两条边界用例同时钉住,而不是只活在 javadoc 里。
+        props = new LeshouyingProperties();
+        service = new LsyOnboardingService(client, props);
     }
 
     /** 把上游报文原文喂给**真实的**解析器,取它真正会交给服务层的那个 Map 当桩的返回值 */
@@ -823,4 +841,321 @@ class LsyOnboardingServiceTest {
         assertEquals(1, shops.size(), "非对象元素不得被映射成一行空门店,实际: " + shops);
         assertEquals("861811140412", shops.get(0).getShopNo());
     }
+
+    // ==================== Task 6:图片上传(上游 page 196236572) ====================
+    // 路径 /api/info/upload/img;字符串部件 img_name + 文件部件 img_file;返回 img_url。
+    // 本方法**只**做「上游 leg」:不加端点、不落库(三-B 的事)。
+
+    /**
+     * 给 multipart 那条腿打桩。口径与 {@link #stubUpstream} 一致 —— 喂**真实**解析器
+     * ({@code parseApi},因为这是 /api/* 的信封),只是把方法换成了 {@code postMultipart}。
+     * 手搓 {@code Map} 会把解析器交给服务层的三种真实形状({@code JSONArray} /
+     * {@code JSONObject} / {@code JSONNull})全换掉。
+     *
+     * <p>★ 只在**确实要出海**的用例里调用它。四条「不该出海」的用例(超限 / null bytes /
+     * 空文件名 / 控制字符)**一次都不打桩** —— 那不是 {@code verifyNoInteractions} 的要求
+     * (打桩不算交互,见 {@link #createShop_blankRequiredField_throws400} 的注释),
+     * 而是打了桩却没人用,会在扩展收尾阶段以 {@code UnnecessaryStubbingException} 变红。
+     */
+    private void stubMultipart(String rawJson) {
+        when(client.postMultipart(anyString(), anyMap(), anyString(), anyString(), any(byte[].class)))
+                .thenReturn(new LeshouyingResponseParser().parseApi(rawJson));
+    }
+
+    /** 一次 {@code postMultipart} 出站调用的实参快照 —— 服务层能负责的最远端。 */
+    private record SentMultipart(String path, Map<String, String> formFields,
+                                 String fileFieldName, String fileName, byte[] fileBytes) {
+    }
+
+    /** 捕获**唯一**一次 multipart 出站调用(多一次、少一次都红)。 */
+    @SuppressWarnings("unchecked")
+    private SentMultipart sentMultipart() {
+        ArgumentCaptor<String> path = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<Map<String, String>> fields =
+                ArgumentCaptor.forClass((Class<Map<String, String>>) (Class<?>) Map.class);
+        ArgumentCaptor<String> fileFieldName = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<byte[]> fileBytes = ArgumentCaptor.forClass(byte[].class);
+        verify(client, times(1)).postMultipart(path.capture(), fields.capture(),
+                fileFieldName.capture(), fileName.capture(), fileBytes.capture());
+        return new SentMultipart(path.getValue(), fields.getValue(),
+                fileFieldName.getValue(), fileName.getValue(), fileBytes.getValue());
+    }
+
+    /**
+     * 断言某次 {@code uploadImage} 被**本地**护栏 400 拒掉(「没有出海」由调用方自己
+     * {@code verifyNoInteractions})。三条一起断言:码是 400、文案非空、**文案不含上游名号**。
+     * 后者是 Global Constraint 2 对本 task 新增字符串的要求 —— 这些文案将来会经三-B 的端点
+     * 原样给到上传方。
+     */
+    private void assertUploadRejected400(byte[] bytes, String fileName, String label) {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.uploadImage(bytes, fileName), label + " ⇒ 必须被本地护栏拒掉");
+        assertEquals(400, ex.getCode(), label);
+        assertNotNull(ex.getMessage(), label);
+        assertFalse(ex.getMessage().isBlank(), label + " 的文案不得为空");
+        assertFalse(ex.getMessage().contains("乐收赢"),
+                label + " 的文案出现了上游名号(Global Constraint 2):" + ex.getMessage());
+    }
+
+    /**
+     * 出站报文形状:字符串部件只有 {@code img_name}、文件部件的字段名是 {@code img_file}、
+     * 文件部件的 filename 与 {@code img_name} **同源**(都来自同一个 {@code fileName} 形参)。
+     *
+     * <p>★「同源」是**我们的收窄**、不是上游规定:文档把 {@code img_name} 列为必填字符串部件、
+     * 示例值({@code 8510_pic_idcard.jpg})与文件名同形,但**没有**规定二者必须一致;
+     * 真不一致时上游的行为我们不知道(**未实测**)。收窄的理由只是不给调用方两个
+     * 可能写出不一致值的入口。
+     *
+     * <p>★ {@code inst_no} / {@code sign} **不在** {@code formFields} 里 —— 由
+     * {@link LeshouyingClient#postMultipart} 内部补({@code signedPayload})。
+     * 这里钉住键集合**恰好**是 {@code {img_name}}:多一个键就是我们把客户端该补的东西
+     * 又送了一遍,而两个键都进签名原文。
+     */
+    @Test
+    void uploadImage_sendsImgNameAndFile() {
+        stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
+
+        service.uploadImage(IMG_BYTES, IMG_NAME);
+
+        SentMultipart sent = sentMultipart();
+        assertEquals("/api/info/upload/img", sent.path(), "上游 page 196236572 的路径");
+        assertEquals("img_file", sent.fileFieldName(), "文件部件的字段名(上游参数表:img_file,必填)");
+        assertEquals(IMG_NAME, sent.fileName(), "文件部件的 filename 与 img_name 同一个形参");
+        assertArrayEquals(IMG_BYTES, sent.fileBytes(), "文件内容原样送出");
+        assertEquals(Set.of("img_name"), sent.formFields().keySet(),
+                "字符串部件恰好只有 img_name(inst_no / sign 归客户端补)");
+        assertEquals(IMG_NAME, sent.formFields().get("img_name"));
+    }
+
+    /** 上游返回的 {@code img_url} 原样带回(返回参数表:{@code result_code} / {@code result_msg} / {@code img_url})。 */
+    @Test
+    void uploadImage_returnsImgUrl() {
+        stubMultipart("{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\",\"img_url\":\"" + IMG_URL + "\"}");
+
+        assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, IMG_NAME));
+    }
+
+    /**
+     * ★ 上游说成功却**没给可用的 {@code img_url}** ⇒ 抛异常,**绝不让 null 一路往外走**。
+     *
+     * <p>理由与 {@link #createShop_missingShopNo_throws} 同源:调用方拿 null 存库就是一条
+     * **查不回来**的记录 —— 图片在上游**确实传上去了**,本地却没有地址。异常类型同样取
+     * {@link BusinessException}(502):手上只有一份 **SUCCESS** 报文,塞进
+     * {@code LeshouyingApiException} 就成了「乐收赢接口失败[SUCCESS]: 操作成功」这句自相矛盾的话。
+     *
+     * <p>三个输入:缺键、**空串**、**全空白**。后两个不是凑数 —— 只测「缺键」的话,
+     * 「只判 null」与「判空白」两种实现分不开,而上游回 {@code "img_url":"   "} 与没回
+     * 一样不可用(口径同 {@code createShop_missingShopNo_throws} 对 {@code shop_no} 的 isBlank)。
+     */
+    @Test
+    void uploadImage_missingImgUrl_throws() {
+        for (String json : new String[]{
+                "{\"result_code\":\"SUCCESS\"}",
+                ok(",\"img_url\":\"\""),
+                ok(",\"img_url\":\"   \"")}) {
+            stubMultipart(json);
+
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.uploadImage(IMG_BYTES, IMG_NAME), json + " ⇒ 必须抛,不得返回 null/空串");
+            assertEquals(502, ex.getCode(), json);
+        }
+    }
+
+    /**
+     * ★ **本地**大小护栏必须在**出站之前**:超一字节就 400,且**一次上游调用都没发出**。
+     *
+     * <p>上限取 {@link LeshouyingProperties#getUploadMaxBytes()} 的**默认值**(不 setter 覆盖,
+     * 见 {@code setUp})—— 这样 2 MiB 这个数字被本条(超一字节)与
+     * {@link #uploadImage_exactlyAtLimit_accepted}(恰好等于)**同时**钉住,谁改掉默认值两条一起红。
+     *
+     * <p>{@code verifyNoInteractions} 抓的正是「护栏的位置」:把它挪到 {@code postMultipart}
+     * 之后,本地照样 400(上面那条断言仍然过),但**报文已经出海了** —— 那时只有这条会红。
+     *
+     * <p>★ 上限是**我们**给「上传方向挂起」这个已知残留加的缓解手段,**不是**上游限制:
+     * 上游文档(page 196236572)没有写大小上限。见
+     * {@code LeshouyingClient#postMultipart} javadoc 末尾「已知残留:大文件没有写超时」。
+     */
+    @Test
+    void uploadImage_oversize_rejectedLocally() {
+        // 默认值 2 MiB 远在 int 范围内;R-T6-2 明令不覆盖 ⇒ 这里可以直接窄化
+        byte[] tooBig = new byte[(int) props.getUploadMaxBytes() + 1];
+
+        assertUploadRejected400(tooBig, IMG_NAME, "超过上限 1 字节");
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * ★ **闭区间**:恰好等于上限必须放行。
+     *
+     * <p>「超过上限」在中文里既能读成 {@code >} 也能读成 {@code >=},代码只能选一个,
+     * 这条把它钉死在 {@code >}:把上限配成 2 MiB 的人,期望的是「2 MiB 的图能传」。
+     */
+    @Test
+    void uploadImage_exactlyAtLimit_accepted() {
+        stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
+        byte[] exactly = new byte[(int) props.getUploadMaxBytes()];
+
+        assertEquals(IMG_URL, service.uploadImage(exactly, IMG_NAME), "恰好等于上限必须放行");
+
+        assertEquals(exactly.length, sentMultipart().fileBytes().length, "送出去的正是那 N 个字节");
+    }
+
+    /** 0 字节 ⇒ 400,且没有出海(空文件不是「一张图」)。同族的正例见 {@link #uploadImage_exactlyAtLimit_accepted}。 */
+    @Test
+    void uploadImage_emptyBytes_rejected() {
+        assertUploadRejected400(new byte[0], IMG_NAME, "0 字节");
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * ★ R-T6-3:{@code bytes == null} 也必须 **400** —— brief 的测试表漏了这一条。
+     *
+     * <p>{@code LeshouyingClient.postMultipart} 入口对 {@code fileBytes == null} 抛的是
+     * {@link IllegalArgumentException}(**程序员错误**语义,而且**不会**被包成
+     * {@code LeshouyingApiException})。本层不拦,它就漏出去,**走哪条路都不好**:
+     * 挂到 {@code LeshouyingOpenController} ⇒ HTTP 200 + code 400,消息里是我们自己的
+     * **内部形参名**原样给到租户;挂到本模块另外四个控制器 ⇒ 落全局兜底 ⇒ HTTP 500 +「服务器内部错误」。
+     * 而这条输入本来就是**上传方的输入**(三-B 从 {@code MultipartFile.getBytes()} 取),
+     * 该变成我们自己的业务前置校验,既不该冒充租户的业务规则、也不该冒充服务器崩溃。
+     *
+     * <p>这**不是**上游要求:上游文档没提,是与 {@code uploadMaxBytes}、控制字符同一族的**我们自己的护栏**。
+     */
+    @Test
+    void uploadImage_nullBytes_rejected() {
+        assertUploadRejected400(null, IMG_NAME, "bytes=null");
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * 扩展名白名单({@code jpg/jpeg/png/bmp}):不在其中、或**根本没有后缀** ⇒ 400。
+     *
+     * <p>★ 白名单是我们**收窄**的,不是上游列举的。依据:上游这些字段全是「照片」语义
+     * ({@code picture_*} 十五处、{@code img_file} / {@code img_name}),且文档示例是 {@code .jpg}。
+     *
+     * <p>★ 末尾那个 {@code a.JPG} 是**正对照**,钉的是那个 {@code toLowerCase}:
+     * 去掉大小写归一,上面三条照样绿,**只有它会红**。
+     *
+     * <p>★ 顺序说明:{@code verifyNoInteractions} 必须排在「通过」那一条**之前** ——
+     * 它要求这个 mock 上零交互,而通过的那条**必然产生交互**。先证「全拒且没出海」,再打桩放行。
+     */
+    @Test
+    void uploadImage_disallowedExtension_rejected() {
+        for (String name : new String[]{"evil.exe", "x.jpg.php", "x"}) {
+            assertUploadRejected400(IMG_BYTES, name, "扩展名不在白名单: " + name);
+        }
+        verifyNoInteractions(client);
+
+        stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
+        assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, "a.JPG"),
+                "大写扩展名必须通过(白名单匹配不区分大小写)");
+    }
+
+    /**
+     * 文件名含路径分隔符 / {@code ..} ⇒ 400。
+     *
+     * <p>是**拒绝**、不是**清洗后照发**:清洗会让日志里的名字与实际发出去的名字不一致 ——
+     * 排障时看到的名字不是上游收到的那个,那比直接拒更坏。
+     */
+    @Test
+    void uploadImage_fileNameWithPathSeparator_rejected() {
+        for (String name : new String[]{"../../etc/passwd.jpg", "a\\b.jpg"}) {
+            assertUploadRejected400(IMG_BYTES, name, "文件名含路径分隔符: " + name);
+        }
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * 文件名 {@code null} / 空串 / 全空白 ⇒ 400,且没有出海。
+     *
+     * <p>★ 这一族必须在**扩展名检查之前**:{@code null} 先在扩展名那段炸就是 **NPE**,不是 400。
+     *
+     * <p>★ 这条是**硬性**的,不是防御性编程:客户端那层虽然也拒 {@code null}
+     * (`postMultipart` 的 {@code requireHeaderSafe}),但它抛的是
+     * {@link IllegalArgumentException}(程序员错误语义),而且**不拒空串与全空白**
+     * —— 它的 javadoc 明写「空串 / 全空白是上游侧校验的活、是调用方(= 本层)的活」。
+     * {@code null} 也不是假想路径:三-B 的 controller 多半从
+     * {@code MultipartFile.getOriginalFilename()} 取,**那个 API 的契约就是可空的**。
+     */
+    @Test
+    void uploadImage_nullOrBlankFileName_rejected() {
+        for (String name : new String[]{null, "", "   "}) {
+            assertUploadRejected400(IMG_BYTES, name,
+                    "fileName=" + (name == null ? "null" : "\"" + name + "\""));
+        }
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * ★ 文件名含 ISO 控制字符 ⇒ 400,且**一次上游调用都没发出**。
+     *
+     * <p>钉的是 **2026-09-22 实测出的 multipart 部件头注入**(不是推测):
+     * {@code FormHttpMessageConverter} 把 {@code fileName} 原样写进 {@code Content-Disposition},
+     * 名里的 CRLF 会在部件头块里**造出一个新的头行**(探针读数
+     * {@code filename="a\r\nX-Evil: 1.jpg"},真 CRLF;裸 LF 同样注入)。
+     *
+     * <p>客户端那层({@code postMultipart} 的 {@code requireHeaderSafe})已经替你兜住了,
+     * 但**两层都要、异常类型不同、理由不同**:客户端抛的是程序员错误语义,而这里的文件名
+     * 来自**上传方**,是**用户输入**,必须变成 {@link BusinessException}(400, 我们自己的中文文案)。
+     *
+     * <p>这同样**不是**上游要求(上游文档没提),是我们的护栏。
+     *
+     * <p>NUL 用八进制转义 {@code \0} 写、不用 {@code \u0000}:后者在**词法分析之前**就会被
+     * Java 的 unicode 转义预处理掉,等于往源码里直接躺一个 NUL —— 同一个字符,但那是编译器
+     * 层面的坑,不是这里要测的东西。
+     */
+    @Test
+    void uploadImage_controlCharInFileName_rejected() {
+        String[][] cases = {
+                {"a\r\nX-Evil: 1.jpg", "CRLF"},
+                {"a\nb.jpg", "裸 LF"},
+                {"a\0b.jpg", "NUL"},
+                {"a\tb.jpg", "tab"},
+        };
+        for (String[] c : cases) {
+            assertUploadRejected400(IMG_BYTES, c[0], "文件名含" + c[1] + "(共 " + c[0].length() + " 字符)");
+        }
+        verifyNoInteractions(client);
+    }
+
+    /**
+     * ★ **正对照**:普通文件名(中文 + 空格)**必须通过**。
+     *
+     * <p>没有这一条,一把**过宽**的控制字符锁(连中文 / 空格也拒)会静默拒掉**所有**真实上传,
+     * 而 {@link #uploadImage_controlCharInFileName_rejected} 照样绿 —— 只验窄不验宽,
+     * 这把锁的**宽度**就没有任何证据。
+     */
+    @Test
+    void uploadImage_ordinaryFilename_accepted() {
+        stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
+        String name = "工资表 2026-09.jpg";
+
+        assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, name));
+
+        SentMultipart sent = sentMultipart();
+        assertEquals(name, sent.fileName(), "空格与中文都不是 ISO 控制字符,必须原样送出");
+        assertEquals(name, sent.formFields().get("img_name"));
+    }
+
+    /**
+     * ★ 这条是「**不做**」的钉子:长文件名**原样送出,不擅自截断**。
+     *
+     * <p>上游文档**没写** {@code img_name} 能接受的长度上限 ⇒ 我们**不设**上游长度上限,
+     * 也不做「顺手截一下」的保护。截断的失败形态是最坏的一种:上游收到的名字与调用方
+     * 以为的不一致,而且**不报错**。
+     *
+     * <p>(brief 里这条原名 {@code uploadImage_fileNameLengthCapped} —— 那个名字描述的是**变异体**
+     * 而不是要求,按 R-T6-6 改名;断言内容一字未动,仍然必须落 M-5 那条红。)
+     */
+    @Test
+    void uploadImage_longFileName_notTruncated() {
+        stubMultipart(ok(",\"img_url\":\"" + IMG_URL + "\""));
+        String longName = "x".repeat(300) + ".jpg";
+
+        assertEquals(IMG_URL, service.uploadImage(IMG_BYTES, longName));
+
+        SentMultipart sent = sentMultipart();
+        assertEquals(longName, sent.fileName(), "长文件名必须原样送出,不得截断");
+        assertEquals(longName, sent.formFields().get("img_name"), "img_name 同样不截断");
+    }
 }