Ver código fonte

feat(进件): 资质图片上传端点的 HTTP 出口(Task 9b-①)

服务腿 LsyOnboardingService.uploadImage 已实现且已测,却零 HTTP 调用方;而
LsyMerchantRequiredFields 把 picture_license / picture_idcard_a / picture_idcard_b /
picture_outside / picture_inside / picture_open_account 放在**多个商户类别的必填清单**里
⇒ 没有这个出口,页面就造不出这些 URL,进件走不通。这不是锦上添花。

- 端点:POST /payment/collection/onboarding/upload-image,multipart/form-data,
  部件名逐字 `file`(注解写 name —— 全仓三处既有 multipart 端点无一例外都写了它)。
- 权限串**逐字**复用 module_payment:leshouying:onboarding:create(不新增第五个权限串
  ⇒ Task 8 已交付的菜单 SQL 一个字不用改)。
- 出参是 Result<String>:**裸字符串、不给这个值起任何键名**。上游回的键名两查皆否
  (不在任何建表语句里、也不在任何对外 DTO/实体里)⇒ 它是上游专有字段名,而
  Global Constraint 1 把「应答字段名」明列为射程内。不起键名 ⇒ 这条风险在构造上不存在。
- 读流失败 ⇒ 400,**不是** 500:不照抄 AccountController 同形端点的 `throws IOException`
  (那会落 GlobalExceptionHandler 的兜底分支 ⇒ HTTP 500「服务器内部错误」)。那是既存形态,
  本任务不修它,但也不复制它 —— 读流失败是「重发这一个文件」,不是「服务器崩了」。
- getOriginalFilename() 为 null ⇒ 换成既有缺省 image.jpg(服务层的护栏对空白文件名抛 400,
  但「客户端没带 filename」这一格的成因在传输层,我们这边有确定行为可用);
  **空串不替换**,仍走服务层那条 400 —— 空串是调用方明确给了个空白名字,两者不同。
- 租户闸 requireTenantId() 与另外几个端点同一口径,排在读字节之前(闸先于一切副作用)。
- 大小上限**不在**这里实现:那道护栏是服务层的 requireUploadable(默认 2 MiB),本端点
  把原始字节整个交给它,所以它够得着、且在出站之前;重复实现一遍就有了两份会漂移的规则。

同时改 LsyOnboardingService 的**一处**既有 502 文案:去掉上游专有键名 img_url。
★ 这处改动**只在开出 HTTP 入口之后才成立**:GlobalExceptionHandler 把 BusinessException 的
message 原样回给调用方,而在此之前这条腿没有任何调用方,这句话到不了租户眼前。原语义
逐字保留(图片确实传上去了 + 不得当作失败直接重传),只去掉那个键名。
服务层上传腿的其它文案不动 —— 那几条 400 已被共享 helper 覆盖 GC1(非空白 + 不含上游名)。
(既有用例 LsyOnboardingServiceTest.uploadImage_missingImgUrl_throws 只断言 code==502、
不断言文案,实测改这条文案不会碰坏它。)

测试(+8 用例,该类 20 → 28):
- 权限串逐字(反射读注解 —— 不用「超管跑一遍能过」证明它,PermissionCheck 对超管直接放行)
- POST + 路径 + multipart;部件名逐字 file(含反例先行:换名字上送必须不被受理)
- 字节与 getOriginalFilename() 逐字到达 service 且**顺序**正确(两个 ArgumentCaptor)
- 应答 data 是那个字符串原样、且**不是**对象/数组(钉 Result<String> 而非 Result<Map>)
- getOriginalFilename() 为 null ⇒ 到达 service 的值非空白且不落 500(用匿名子类把
  getOriginalFilename() 覆写成真 null —— MockMultipartFile 会把 null 归一成空串,
  不覆写就是在测另一格,实测踩到)
- 读流失败 ⇒ 400 + 零上游痕迹(用匿名子类让 getBytes() 抛 IOException)
- 上游没给图片地址 ⇒ 502 且文案零上游痕迹、原语义仍在

两向变异(9 个):该红的 7 个全红 —— 摘租户闸 / 部件名改 image / 去掉 null 缺省 /
出口改成 500 / 返回值不原样 / 502 文案退回去带键名 / 改 400 文案(最后这个是 GC1 扫描的
**正对照**,它必须能红,否则那条扫描是恒真的);无害的 2 个实测仍绿 —— 形参改名、
缺省文件名改值,且都当场用 javap 证明变异体进了被执行的 class(绿向要 class 级证据)。

实测:该类 28 绿;全量 631 绿(改前 623,BUILD SUCCESS)。
alphaH 3 semanas atrás
pai
commit
1282dbbe64

+ 95 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -25,12 +25,14 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
+import java.io.IOException;
 import java.util.List;
 import java.util.Set;
 
 /**
- * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询,四个端点。
+ * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询 / 资质图片上传,五个端点。
  *
  * <p><b>【本类存在的理由:这条腿在 Task 7 之前根本没有 HTTP 入口】</b>
  * Task 3–6 把 {@link LsyOnboardingService} 的四条腿做完了(建号 / 更新 / 提交入网 / 入网状态查询),
@@ -59,7 +61,7 @@ import java.util.Set;
  *
  * <h2>端点清单(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)</h2>
  * <table border="1">
- *   <caption>三个权限串 → 四个端点</caption>
+ *   <caption>三个权限串 → 五个端点</caption>
  *   <tr><th>方法</th><th>完整路径</th><th>权限串</th><th>带 mchNo</th><th>租户闸落在哪</th></tr>
  *   <tr><td>GET</td><td>{@code /payment/collection/onboarding/page}</td>
  *       <td>{@value #PERM_ONBOARDING_LIST}</td><td>否</td>
@@ -75,6 +77,10 @@ import java.util.Set;
  *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否</td>
  *       <td>{@link #requireTenantId()}(区划树是**全局参考数据**、无租户维度,挂闸是为「一律」这条
  *           统一口径,不是为挡越权 —— 见 {@link #areas})</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/collection/onboarding/upload-image}</td>
+ *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否</td>
+ *       <td>{@link #requireTenantId()}(上传出来的图片在上游是**平台级**的、不绑定租户,
+ *           挂闸同样是为「一律」这条统一口径 —— 见 {@link #uploadImage})</td></tr>
  * </table>
  *
  * <p>★ 权限串在注解里写成**字面量**(不是 {@code PERM_*} 常量拼接):全仓
@@ -201,6 +207,93 @@ public class LeshouyingOnboardingController {
         return Result.ok();
     }
 
+    // ──────────────── 资质图片上传端点(加记 33) ────────────────
+
+    /**
+     * {@code getOriginalFilename()} 回 {@code null} 时的缺省文件名。
+     *
+     * <p>为什么需要一个缺省值而不是把 {@code null} 交给服务层:那个 API 的契约就是**可空**的
+     * (服务层自己的 javadoc 逐字写着这一点),而服务层的六道护栏对**空白**文件名抛 400
+     * —— 那是它该做的(上传方没给名字确实是它的输入问题),
+     * 但「客户端没带 filename」这一格的成因在**传输层**,我们这边有确定行为可用。
+     * 取 {@code AccountController} 同形端点的既有缺省值,不另造一个。
+     *
+     * <p>★ 这一格与「传空串」的行为**不同**(空串仍走服务层的 400),这是**有意**的:
+     * 空串是调用方明确给了一个空白名字,{@code null} 是它压根没法给。
+     */
+    private static final String DEFAULT_UPLOAD_FILENAME = "image.jpg";
+
+    /**
+     * 资质图片上传 —— 进件页所有 {@code picture_*} 字段的取值来源(上游「上传图片」page 196236572)。
+     *
+     * <p><b>【为什么这个端点必须存在】</b>计划 Task 9 强制要求进件页做「资质图片上传」,
+     * 而在此之前**全仓没有任何 HTTP 出口**把这条腿给前端;服务腿
+     * {@link LsyOnboardingService#uploadImage} 已实现且已测,却**零 HTTP 调用方**。
+     * ★ 这不是锦上添花:{@code LsyMerchantRequiredFields} 把 {@code picture_license} /
+     * {@code picture_idcard_a} / {@code picture_idcard_b} / {@code picture_outside} /
+     * {@code picture_inside} / {@code picture_open_account} 等放在**多个商户类别的必填清单**里
+     * ⇒ 没有本端点,页面造不出这些 URL ⇒ **进件走不通**。
+     *
+     * <p>★★ <b>应答是裸字符串,不是 {@code Map}。</b>上游回的键名是**上游专有**
+     * (两查皆否:建表语句里没有那一列、也没有任何对外 DTO/实体把它回给租户
+     * —— 读数见 {@code task-9b-report.md}),而 Global Constraint 1 把「**应答字段名**」
+     * 明列为射程内。**不给这个值起任何键名** ⇒ 这条风险在构造上不存在。
+     * 先例:{@code ParamsController.value} 直接回 {@code Result<String>}。
+     *
+     * <p>★ <b>读流失败 ⇒ 400,不是 500。</b>{@code AccountController} 的同形端点直接
+     * {@code throws IOException} ⇒ 落 {@code GlobalExceptionHandler} 的 catch-all ⇒
+     * HTTP 500「服务器内部错误」。那是**既存形态**(本任务不修它),但**不复制它**:
+     * 读流失败是**这一次上传**的问题,语义是「重发这一个文件」,不是「服务器崩了」。
+     *
+     * <p>★ <b>大小上限在服务层,本端点不重复实现它</b>:那道护栏是
+     * {@code LsyOnboardingService#requireUploadable}(上限取
+     * {@code LeshouyingProperties.uploadMaxBytes},默认 2 MiB)—— 本端点把**原始字节整个**
+     * 交给它,所以它够得着,且出站在它之后(护栏拒掉时一个字节都不出海)。
+     * 它低于容器上限({@code application.yml} 的 {@code max-file-size: 10MB} /
+     * {@code max-request-size: 20MB})是**有意**的:3 MiB 的文件能穿过容器、被服务层 400 拒掉
+     * —— 若容器上限更低,这道护栏就永远够不着。超过 10MB 会先撞容器 ⇒
+     * {@code MaxUploadSizeExceededException} ⇒ catch-all ⇒ 500;那是**全仓每个 multipart
+     * 端点的既存形态**,修它要动共享处理器,不属本任务。
+     *
+     * <p>★ <b>租户闸</b>:本端点与另外几个同一口径({@code requireTenantId()} 打头,见类 javadoc)。
+     * 服务层收不到任何租户标识 —— 上传出来的图片在上游是**平台级**的,不绑定任何租户
+     * (服务层 javadoc 把这一点记为 R-4 的同类型前置条件)。
+     *
+     * @param file 图片部件。**部件名逐字是 {@code file}** —— 注解里的 {@code name} 是线上名,
+     *             改它前端就绑不上(全仓三处既有 multipart 端点 {@code FileController} /
+     *             {@code ParamsController} / {@code AccountController} 无一例外都写了 {@code name})
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+    @PostMapping("/upload-image")
+    public Result<String> uploadImage(@RequestParam("file") MultipartFile file) {
+        // 与另外几个端点同一条统一口径:本控制器的端点一律要求非空租户(见类 javadoc)。
+        // 排在读字节之前 —— 闸先于一切副作用。
+        requireTenantId();
+        return Result.ok(onboardingService.uploadImage(readBytes(file), uploadFileName(file)));
+    }
+
+    /**
+     * 读部件的字节;读不出来 ⇒ {@link BusinessException}(400),**不是** 500。
+     *
+     * <p>文案零上游痕迹(Global Constraint 1)—— 它经 {@code GlobalExceptionHandler}
+     * **原样**回给调用方。明细(文件名 + 异常)只进日志,日志里打的是文件名不是内容。
+     */
+    private byte[] readBytes(MultipartFile file) {
+        try {
+            return file.getBytes();
+        } catch (IOException e) {
+            log.error("读取上传图片失败:fileName={}, 异常={}",
+                    LsyLogSanitizer.flatten(file.getOriginalFilename()), e.toString(), e);
+            throw new BusinessException(400, "图片上传失败:读取上传内容时出错,请重试");
+        }
+    }
+
+    /** 传给服务层的文件名 —— {@code null} 换成 {@link #DEFAULT_UPLOAD_FILENAME},其余**原样**(不 trim、不清洗)。 */
+    private static String uploadFileName(MultipartFile file) {
+        String original = file.getOriginalFilename();
+        return original == null ? DEFAULT_UPLOAD_FILENAME : original;
+    }
+
     // ──────────────── 省市区只读端点(加记 29 §二 裁定 R-T9a) ────────────────
 
     /** {@code level} 的白名单 —— 本端点**自己收窄**的,服务层没有这条规则。 */

+ 5 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -1037,8 +1037,12 @@ public class LsyOnboardingService {
                 .postMultipart(PATH_UPLOAD_IMG, form, FILE_FIELD_IMG, fileName, bytes)
                 .get("img_url"));
         if (StrUtil.isBlank(imgUrl)) {
+            // ★ 文案里**不得**出现上游那个键名(Global Constraint 1):它经
+            //   GlobalExceptionHandler **原样**回给调用方,而三-B 的上传端点就是它的第一处
+            //   HTTP 出口(在端点存在之前这句到不了租户眼前,所以它是随端点一起才成立的改动)。
+            //   原语义保留:图片**确实传上去了** + 不得直接重传。
             throw new BusinessException(502,
-                    "上传成功但缺少图片地址 img_url(图片可能已上传成功,不得当作失败直接重传)");
+                    "上传成功但未返回图片地址(图片可能已上传成功,不得当作失败直接重传)");
         }
         return imgUrl;
     }

+ 326 - 5
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -11,6 +11,8 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.exception.GlobalExceptionHandler;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.core.security.LoginUser;
+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.LsyCommitMerchantInfoDTO;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
@@ -32,19 +34,24 @@ import org.springframework.dao.DataIntegrityViolationException;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.http.MediaType;
 import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+import org.springframework.mock.web.MockMultipartFile;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.test.web.servlet.MockMvc;
 import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.multipart.MultipartFile;
 
+import java.io.IOException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Parameter;
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
+import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.LinkedHashSet;
@@ -59,6 +66,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.*;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -96,6 +104,20 @@ class LeshouyingOnboardingControllerTest {
     /** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
     private static final String AREAS_PATH = PREFIX + "/areas";
 
+    // ---- 资质图片上传端点(加记 33)用的夹具 ----
+
+    /** 上传端点的完整路径。 */
+    private static final String UPLOAD_PATH = PREFIX + "/upload-image";
+
+    /** 一张「图」的字节。上传这条腿只看**内容原样到达**,与图片是不是真的无关。 */
+    private static final byte[] IMG_BYTES = "fake-jpeg-bytes".getBytes(StandardCharsets.UTF_8);
+
+    /** 文件名 —— 扩展名必须在服务层白名单(jpg/jpeg/png/bmp)内,否则那一格测的是护栏而不是透传。 */
+    private static final String UPLOAD_NAME = "license.jpg";
+
+    /** service 返回的图片地址。断言的是**原样回给调用方**,所以取一个不会与别的字符串碰巧相等的值。 */
+    private static final String IMG_URL = "https://img.example.invalid/picture/xxx.jpg";
+
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyOnboardingService onboardingService;
 
@@ -357,7 +379,7 @@ class LeshouyingOnboardingControllerTest {
     /**
      * 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
      *
-     * <p><b>四个端点一起钉</b>(含 {@code /areas}):{@code page} 不带 {@code mchNo},但它同样是
+     * <p><b>每个端点一起钉</b>(含 {@code /areas} 与 {@code /upload-image}):{@code page} 不带 {@code mchNo},但它同样是
      * 「以租户为范围」的读 —— 一个没有租户的主体在 {@code TenantInnerInterceptor} 下
      * **不会被追加任何谓词**(Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
      * {@code /areas} 是**全局参考数据**(区划树对每个租户都一样,服务层也不带租户),它挂闸
@@ -684,6 +706,299 @@ class LeshouyingOnboardingControllerTest {
         verify(onboardingService).areaList("1", "   ");
     }
 
+    // ──────────────── 资质图片上传端点(加记 33 裁定 R-T9c) ────────────────
+
+    /**
+     * ★ 权限串**逐字**是 {@code :create}(同 {@link #areasEndpoint_reusesTheCreatePermissionStringVerbatim}
+     * 的理由:超管直通 ⇒ 「超管跑一遍能过」证明不了任何权限串)。
+     *
+     * <p>【为什么必须是 {@code :create}】{@code LsyMerchantRequiredFields} 把
+     * {@code picture_license} 一族放在**多个商户类别的必填清单**里 ⇒ 造不出这些 URL 的进件走不通;
+     * 而填这些 URL 的能力属于「建号」⇒ 复用既有的 {@code :create},**不新增第五个权限串**
+     * (Task 8 已交付的菜单 SQL 一个字都不用改、用户也不需要重新执行那份 SQL)。
+     */
+    @Test
+    void uploadImageEndpoint_reusesTheCreatePermissionStringVerbatim() throws Exception {
+        PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(uploadImageMethod(), PreAuthorize.class);
+
+        assertNotNull(a, "upload-image 端点缺 @PreAuthorize —— 端点会变成任何登录用户可调");
+        assertEquals("@perm.hasAny('" + LeshouyingOnboardingController.PERM_ONBOARDING_CREATE + "')",
+                a.value(),
+                "权限串必须逐字复用 :create(不新增第五个权限串)。实测: " + a.value());
+    }
+
+    /**
+     * ★ 路由 + 方法 + 「请求是 multipart」。
+     *
+     * <p>{@code multipart} 那一半的判据是**形参类型**:端点收的是一个 {@link MultipartFile}
+     * 部件(而不是把文件塞进 JSON 体的某个字符串字段里)—— 后者会把二进制内容过一遍
+     * base64 与 JSON 转义,且**零件名**这件事就无从谈起了。
+     */
+    @Test
+    void uploadImageEndpoint_isPostOnUploadImagePath() throws Exception {
+        Method upload = uploadImageMethod();
+
+        PostMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(upload, PostMapping.class);
+        assertNotNull(methodMapping, "upload-image 端点必须是 POST(它写状态:往上流传一张图)");
+        assertEquals(List.of("/upload-image"), declaredPaths(methodMapping.value(), methodMapping.path()),
+                "方法级路径必须是 /upload-image。实测: "
+                        + declaredPaths(methodMapping.value(), methodMapping.path()));
+
+        RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(
+                LeshouyingOnboardingController.class, RequestMapping.class);
+        assertEquals(List.of(PREFIX), declaredPaths(classMapping.value(), classMapping.path()),
+                "类级前缀(完整路径 = 前缀 + /upload-image)。实测: "
+                        + declaredPaths(classMapping.value(), classMapping.path()));
+
+        assertEquals(1, upload.getParameterCount(), "本端点只收一个部件");
+        assertEquals(MultipartFile.class, upload.getParameterTypes()[0],
+                "形参必须是 MultipartFile —— 那才是 multipart 请求的载体。实测: "
+                        + List.of(upload.getParameterTypes()));
+    }
+
+    /**
+     * ★ 部件名**逐字**是 {@code file}:两半一起钉。
+     *
+     * <p>【钉的是注解里的 {@code name},不是形参名】形参可以随便改(Java 侧的事),
+     * 线上名不能 —— 改了它,前端用 {@code file} 上送就再也绑不上,而现象是「部件不见了」。
+     * 全仓三处既有 multipart 端点({@code FileController} / {@code ParamsController} /
+     * {@code AccountController})**无一例外**都写了 {@code name},这里照抄那个形状。
+     *
+     * <p>【实测那一半为什么是「反例先行」】只测「用 {@code file} 上送能成功」的话,
+     * 一个「随便什么部件名都收」的实现也会绿。所以先送一个**别的**部件名,
+     * 断言 service **一次都没被碰过**({@code verifyNoInteractions} 是一条无歧义的零接触读数),
+     * 再送 {@code file} 作正对照。
+     */
+    @Test
+    void uploadImageEndpoint_bindsTheWirePartNameFileVerbatim() throws Exception {
+        Parameter p = uploadImageMethod().getParameters()[0];
+        // ★ 走 findMergedAnnotation 而不是 p.getAnnotation(...):RequestParam 的 value 与 name 是一对
+        //   @AliasFor,类文件里**只存显式写下的那一个**,另一个读出来是 ""(2026-09-23 实测:
+        //   @RequestParam("file") 经 getAnnotation 读到的 name() 是空串)。合并注解把两者都填上。
+        RequestParam ann = AnnotatedElementUtils.findMergedAnnotation(p, RequestParam.class);
+        assertNotNull(ann, "部件形参缺 @RequestParam");
+        assertEquals("file", ann.value(), "注解的 value 必须逐字是 file。实测: " + ann.value());
+        assertEquals("file", ann.name(), "注解的 name 必须逐字是 file。实测: " + ann.name());
+
+        login(TENANT, false);
+
+        // 反例先行:别的部件名 ⇒ **不被识别**(这条钉的是注解里的 name,不是形参名)
+        int wrongPartStatus = mockMvc.perform(multipart(UPLOAD_PATH)
+                        .file(new MockMultipartFile("image", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
+                .andReturn().getResponse().getStatus();
+        assertNotEquals(200, wrongPartStatus,
+                "用别的部件名上送不得被**受理**。实测 HTTP: " + wrongPartStatus);
+        verifyNoInteractions(onboardingService);
+
+        // 正对照:部件名 file ⇒ 受理(否则上面那条「不被识别」可能只是因为压根没路由到这个方法)
+        when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
+        mockMvc.perform(multipart(UPLOAD_PATH)
+                        .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+        verify(onboardingService).uploadImage(any(), eq(UPLOAD_NAME));
+    }
+
+    /**
+     * ★ 透传:service 收到的第一个实参是**文件的字节**、第二个是
+     * {@code getOriginalFilename()} 的**原值**,**顺序也对**。
+     *
+     * <p>顺序不是形式问题:{@code uploadImage(byte[] bytes, String fileName)} 两个形参类型不同,
+     * 反着传编译就过不去;这条断言真正钉的是「**没有**在中间做任何加工」——
+     * 例如把文件名换成固定值、或把字节 base64 之后当字符串传。
+     * 用两个 captor 而不是 {@code eq(byte[])}:数组的 {@code equals} 是**同一性**比较,
+     * {@code eq} 在这里恒假。
+     *
+     * <p>字节用 {@code assertArrayEquals} 逐字比,不是比长度 —— 上传这条腿的载荷就是内容本身。
+     */
+    @Test
+    void uploadImageEndpoint_passesBytesAndOriginalFilenameThrough_verbatimAndInOrder() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
+
+        mockMvc.perform(multipart(UPLOAD_PATH)
+                        .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
+                .andExpect(jsonPath("$.code").value(0));
+
+        ArgumentCaptor<byte[]> bytes = ArgumentCaptor.forClass(byte[].class);
+        ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
+        // 两个 captor 按**位置**绑定 ⇒ 这一行同时钉住「值逐字」与「顺序(第一个是字节)」
+        verify(onboardingService).uploadImage(bytes.capture(), fileName.capture());
+
+        assertArrayEquals(IMG_BYTES, bytes.getValue(), "文件字节必须原样到达 service");
+        assertEquals(UPLOAD_NAME, fileName.getValue(), "getOriginalFilename() 的原值必须到达 service");
+    }
+
+    /**
+     * ★ 应答形状:成功时 {@code data} 就是 service 返回的那个字符串**原样**,
+     * 且它是**裸字符串**、不是一个对象/数组。
+     *
+     * <p>【这一条同时是 GC1 的钉子】上游回的键名是**上游专有**(两查皆否:建表语句里没有那一列、
+     * 也没有任何对外 DTO/实体把它回给租户 —— 读数见 {@code task-9b-report.md}),
+     * 而 Global Constraint 1 把「**应答字段名**」明列为射程内。
+     * **不给这个值起任何键名** ⇒ 这条风险在构造上不存在(不是「起个安全的名字」,是「不起名字」)。
+     * {@code isString()} 那一条断言就是「它没被包进 Map」的机器证据。
+     */
+    @Test
+    void uploadImageEndpoint_returnsTheServiceStringVerbatim_andNotAnObject() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
+
+        mockMvc.perform(multipart(UPLOAD_PATH)
+                        .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0))
+                .andExpect(jsonPath("$.data").isString())
+                .andExpect(jsonPath("$.data").value(IMG_URL));
+    }
+
+    /**
+     * ★ {@code getOriginalFilename()} 回 {@code null} 时**不得 NPE、不得落 500**,
+     * 且交给 service 的必须是一个**非空白**的文件名。
+     *
+     * <p>【为什么 null 是真实可达的】{@code Part.getSubmittedFileName()}({@code MultipartFile}
+     * {@code getOriginalFilename()} 的实现)在部分客户端 / 构造下**回 null** —— 服务层自己的
+     * javadoc 就把这条记为「{@code null} 是真实可达的:三-B 多半从
+     * {@code MultipartFile.getOriginalFilename()} 取,**那个 API 的契约就是可空的**」。
+     *
+     * <p>【为什么端点必须补一个缺省值,而不是把 null 交给 service】service 的六道护栏对
+     * **空白**文件名抛 400(那是**正确**的出口,因为「上传方没给名字」确实是它的输入问题)——
+     * 但 {@code null} 这一格的成因在**传输层**(客户端没带 filename),
+     * 我们这边有确定行为可用,就没有理由把它变成一次 500。
+     * 代价是这一格与「传空串」的行为**不同**(空串仍走 service 的 400)——这是**有意**的:
+     * 空串是调用方明确给了一个空白名字,{@code null} 是它压根没法给。
+     */
+    @Test
+    void uploadImageEndpoint_nullOriginalFilename_reachesServiceNonBlank_without500() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.uploadImage(any(), any())).thenReturn(IMG_URL);
+
+        // ★ 用匿名子类把 getOriginalFilename() 强制成 null —— 不能靠
+        //   new MockMultipartFile("file", null, …):那个类的构造器把 null **归一成空串**
+        //   (2026-09-23 实测:传 null 进去、读出来是 "",于是这一格测的是「空串」而不是「null」,
+        //   而这两种输入在本端点上的行为**不同**,见 uploadImage 的 javadoc)。
+        //   这里模拟的正是那个契约允许的输入:客户端送来的部件根本没有 filename。
+        MockMultipartFile noFilename = new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES) {
+            @Override
+            public String getOriginalFilename() {
+                return null;
+            }
+        };
+        assertNull(noFilename.getOriginalFilename(), "夹具前提:这个部件的文件名确实是 null");
+        // 量具正对照(不是产品要求):**不覆写**时同一个构造器把 null 归一成空串 ——
+        // 上面那个匿名子类因此是必需的,否则这一格悄悄变成「传空串」的用例。
+        // 若这条哪天红了(Spring 改了 MockMultipartFile),请连同上面那段注释一起更新。
+        assertEquals("", new MockMultipartFile("file", null, "image/jpeg", IMG_BYTES).getOriginalFilename(),
+                "MockMultipartFile 把 null 文件名归一成空串(量具的既有行为)");
+
+        mockMvc.perform(multipart(UPLOAD_PATH).file(noFilename))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        ArgumentCaptor<String> fileName = ArgumentCaptor.forClass(String.class);
+        verify(onboardingService).uploadImage(any(), fileName.capture());
+        assertNotNull(fileName.getValue(),
+                "getOriginalFilename() 为 null 时不得把 null 交给 service");
+        assertFalse(fileName.getValue().isBlank(),
+                "交给 service 的文件名必须非空白(service 对空白名抛 400,而这一格是我们自己的缺省)。实测: "
+                        + fileName.getValue());
+    }
+
+    /**
+     * ★ 读字节失败({@code MultipartFile.getBytes()} 抛 {@code IOException})⇒
+     * **400**(HTTP 200 —— 本仓 {@code BusinessException} 的既有出口),**不是 500**。
+     *
+     * <p>【为什么这条必须有用例】{@code AccountController} 的同形端点直接
+     * {@code throws IOException} ⇒ 落 {@code GlobalExceptionHandler} 的 catch-all ⇒
+     * <b>HTTP 500「服务器内部错误」+ 零信息</b>。那是既存形态(本任务不修它),
+     * 但**不复制它**:读流失败是**这一次上传**的问题,语义是「重发这一个文件」,
+     * 不是「服务器崩了」。
+     *
+     * <p>【文案为什么也要断言】它经 {@code GlobalExceptionHandler} **原样**回给调用方
+     * ⇒ Global Constraint 1(文案零上游痕迹)对它成立。这里用 4 个上游特征串做一次**窄**检查
+     * —— 与 {@code LsyOnboardingServiceTest.assertUploadRejected400} 对
+     * 服务层那批新文案做的是同一种检查(那里只查「乐收赢」一个词),
+     * 不是本文件在别处否掉的那种「大词表黑名单」。
+     */
+    @Test
+    void uploadImageEndpoint_unreadableBytes_is400NotServerError() throws Exception {
+        login(TENANT, false);
+        MockMultipartFile broken = new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES) {
+            @Override
+            public byte[] getBytes() throws IOException {
+                throw new IOException("模拟读流失败");
+            }
+        };
+
+        // ★ 必须显式给 UTF-8:应答的 Content-Type 不带 charset(JSON 按规范就是 UTF-8),
+        //   而无参的 getContentAsString() 会用 ISO-8859-1 解 ⇒ 中文全成问号 ⇒
+        //   下面那个「不含上游痕迹」的循环会**恒真**(2026-09-23 实测踩到,见 task-9b-report.md)。
+        String body = mockMvc.perform(multipart(UPLOAD_PATH).file(broken))
+                .andExpect(status().isOk())     // 本仓 BusinessException 的既有出口(不是 500)
+                .andExpect(jsonPath("$.code").value(400))
+                .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+
+        // 正对照(量具有区分度):我们自己写的那句文案必须**读得出来** —— 它是中文,
+        // 解码一错它就消失。没有这一条,下面那个循环就是一条恒真的假断言。
+        assertTrue(body.contains("读取上传内容时出错"),
+                "应答体没被正确解码(或文案没到达调用方)。实测应答: " + body);
+        // 词汇表逐字取自 task-9b-brief.md ①-step1 第 8 条的检索式(8 个 token)——
+        // 与报告里那把量具共用**同一份**词汇表,免得两处各扫各的、覆盖面悄悄分叉。
+        for (String upstreamTrace : List.of("乐收赢", "gdyfsk", "yfpay", "img_url",
+                "shoubei", "merchant_no", "rate_code", "leshouying")) {
+            assertFalse(body.contains(upstreamTrace),
+                    "400 文案里出现了上游痕迹「" + upstreamTrace + "」(Global Constraint 1)。实测应答: " + body);
+        }
+
+        // 读流失败发生在**调 service 之前** ⇒ 一次都没有触达下游
+        verifyNoInteractions(onboardingService);
+    }
+
+    /**
+     * ★ 上游说成功却**没给图片地址** ⇒ 502,且文案里**不得出现上游那个键名**。
+     *
+     * <p>【为什么这条用例在**端点**层】那句话经 {@code GlobalExceptionHandler} **原样**回给调用方,
+     * 而本任务正是它的第一处 HTTP 出口 —— 在端点存在之前,它到不了任何租户眼前。
+     * 服务层的既有用例({@code LsyOnboardingServiceTest.uploadImage_missingImgUrl_throws})
+     * 只断言 {@code ex.getCode() == 502}、**不断言文案** ⇒ 「文案里有没有那个键名」在那一层
+     * 是没有任何用例约束的。这条从 HTTP 出口钉两件事:出口是 502(不是 500、也不是 200),
+     * 且文案里没有那个键名、而**原语义仍在**。
+     *
+     * <p>本用例装配一个**真**的 {@link LsyOnboardingService}(只 mock 掉 {@link LeshouyingClient})
+     * —— 与 {@link #createEndpoint_persistsRowWithPendingStatus} 用真 {@code LsyMerchantService}
+     * 同一条理由:要断言的是**服务层产生的那一个字符串**,把服务 mock 掉就把被测对象一起 mock 掉了。
+     */
+    @Test
+    void uploadImageEndpoint_missingImageUrl_is502WithNoUpstreamKeyName() throws Exception {
+        login(TENANT, false);
+        LeshouyingClient client = mock(LeshouyingClient.class);
+        // 上游只回了一个 SUCCESS 信封、没有图片地址 ⇒ 服务层判空 ⇒ 502。
+        // 这里直接给 map(不走解析器):本用例钉的是**缺键那条分支的出口**,不是解析器
+        // —— 解析器由 LsyOnboardingServiceTest 全覆盖。
+        when(client.postMultipart(any(), any(), any(), any(), any()))
+                .thenReturn(Map.<String, Object>of("result_code", "SUCCESS", "result_msg", "操作成功"));
+        MockMvc mvc = standaloneMvc(new LeshouyingOnboardingController(merchantService,
+                new LsyOnboardingService(client, new LeshouyingProperties())));
+
+        String body = mvc.perform(multipart(UPLOAD_PATH)
+                        .file(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)))
+                .andExpect(status().isOk())                     // 本仓 BusinessException 的既有出口
+                .andExpect(jsonPath("$.code").value(502))
+                .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
+
+        // 词汇表逐字取自 task-9b-brief.md ①-step1 第 8 条的检索式(8 个 token)——
+        // 与报告里那把量具共用**同一份**词汇表,免得两处各扫各的、覆盖面悄悄分叉。
+        for (String upstreamTrace : List.of("乐收赢", "gdyfsk", "yfpay", "img_url",
+                "shoubei", "merchant_no", "rate_code", "leshouying")) {
+            assertFalse(body.contains(upstreamTrace),
+                    "502 文案里出现了上游痕迹「" + upstreamTrace + "」(Global Constraint 1)。实测应答: " + body);
+        }
+        // 原语义必须**还在**:图片确实传上去了 + 不得直接重传。
+        // 只钉「不含键名」是黑名单式的半个钉子 —— 把整句删掉也能绿,所以这里配一条正向断言。
+        assertTrue(body.contains("不得当作失败直接重传"),
+                "502 文案丢了「图片可能已上传成功、不得直接重传」这条行为指令。实测应答: " + body);
+    }
+
     // ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
 
     /**
@@ -715,9 +1030,9 @@ class LeshouyingOnboardingControllerTest {
                 LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
 
         List<Method> mapped = mappedMethods();
-        assertEquals(4, mapped.size(),
-                "本控制器应恰好四个端点(三个权限串 → 四个端点:第四个 /areas 复用 :create,"
-                        + "加记 29 §二 裁定 R-T9a)。实测: "
+        assertEquals(5, mapped.size(),
+                "本控制器应恰好五个端点(三个权限串 → 五个端点:/areas 与 /upload-image 都复用 :create,"
+                        + "加记 29 §二 裁定 R-T9a 与加记 33)。实测: "
                         + mapped.stream().map(Method::getName).toList());
 
         Set<String> onAnnotations = new LinkedHashSet<>();
@@ -812,6 +1127,11 @@ class LeshouyingOnboardingControllerTest {
         return LeshouyingOnboardingController.class.getDeclaredMethod("areas", String.class, String.class);
     }
 
+    /** 资质图片上传端点的方法本身(形参类型建在 {@link MultipartFile} 上 —— 它就是「部件」这一半)。 */
+    private static Method uploadImageMethod() throws NoSuchMethodException {
+        return LeshouyingOnboardingController.class.getDeclaredMethod("uploadImage", MultipartFile.class);
+    }
+
     /**
      * 注解上声明的路径。{@code value} 与 {@code path} 互为别名,而
      * {@code AnnotatedElementUtils.findMergedAnnotation} 会把**两者都填上**同一个值
@@ -917,7 +1237,8 @@ class LeshouyingOnboardingControllerTest {
                 () -> controller.page(1, 20, null),
                 () -> controller.create(createBody("E1", "示例商户")),
                 () -> controller.commit(commitBody(MCH_NO)),
-                () -> controller.areas("1", null));
+                () -> controller.areas("1", null),
+                () -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
     }
 
     /** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */