Pārlūkot izejas kodu

feat(进件): Task 7 进件 HTTP 端点与租户归属闸(三个端点 + 两个服务入口)

把三-B 的进件腿暴露成管理端 HTTP。在 Task 7 之前,LsyOnboardingService 的四条腿
(建号 / 更新 / 提交入网 / 状态查询)一个 HTTP 调用方都没有,只有单测在调它。

三个权限串对应三个端点(不新增第四个):

  GET  /payment/collection/onboarding/page    module_payment:leshouying:onboarding:list
  POST /payment/collection/onboarding/create  module_payment:leshouying:onboarding:create
  POST /payment/collection/onboarding/commit  module_payment:leshouying:onboarding:commit

权限串在注解里写字面量(不是常量拼接),好让 grep 能命中 @PreAuthorize 那一行;
一致性由反射用例把「注解值 == 常量 == 测试里的字面量」钉死。权限是全等匹配且超管直通,
用超管跑的用例一个都测不出写错的串,所以这处独立核对不能省。

归属只来自认证上下文(照 EnterpriseController.currentUser 取 LoginUser),取不到就 403,
不带着 null 往下走。请求体里出现 tenant_id 一律拒成 400,同 LeshouyingMerchantController.save
的教训;这里选拒绝而不是忽略,因为忽略会让写错这个字段的调用方拿到「成功」并以为指定生效了。
带 mchNo 的端点第一行就是 requireByMchNoForTenant(mchNo, tenantId),不存在
「先 requireByMchNo 再在 Java 里比 tenantId」这条路,那已经把别租户的 PII 读进内存了。

载荷级的活(计划原先漏掉的):createMerchant 只从上游换回一个商户号、不写库,于是
pay_lsy_merchant 里不会有任何一行带这个 mch_no,而 Task 6 的审核通知腿正是按 mch_no
定位本地行的,它每一次都会走「找不到行」,上游审核结果写不进任何状态。新增
createOnboardingRow 落这一行:review_status 写 PENDING(不落 DDL 默认的 APPROVED,
也不复用 save 的新增分支,那里把这两列钉成 APPROVED/ENABLED)、tenant_id 由实参钉住
(不靠 AutoFillMetaObjectHandler 从上下文补,那会让漏传租户的调用方静默拿到当前登录者的租户)、
enterprise_id 取自请求体。顺序是先建号后落行,上游抛错时一行都不写。

requireByMchNoForTenant 用 selectList 加显式判 0/1/多行(0 行 404,多行 409 并记 ERROR
带命中的 id),不用 selectOne 也不用 LIMIT 1:真库里 mch_no='Y52211090004' 就有 2 行同租户,
LIMIT 1 会静默取任意一行。它的 javadoc 逐字落 Step 0 那张表(超管 / tenantId!=null /
tenantId==null / 无认证上下文)与量法。租户内查询让 404 在「不存在」与「不属于本租户」上
不可区分,不构成跨租户探针。

测试 +13(控制器 8 / 服务 5),全量 611 绿。已知残留:page 端点的行级过滤仍靠
TenantInnerInterceptor,本控制器唯一一处不自带谓词的读,已在报告中列出。
alphaH 3 nedēļas atpakaļ
vecāks
revīzija
247abcdf47

+ 229 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -0,0 +1,229 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.PageResult;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+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;
+
+/**
+ * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网,三个端点。
+ *
+ * <p><b>【本类存在的理由:这条腿在 Task 7 之前根本没有 HTTP 入口】</b>
+ * Task 3–6 把 {@link LsyOnboardingService} 的四条腿做完了(建号 / 更新 / 提交入网 / 入网状态查询),
+ * 但它的**唯一**调用方一直是单测;真正到达租户的路必须由管理端提供,而管理端有**认证上下文**
+ * —— 这正是 {@code LsyOnboardingService} 类 javadoc 里那条租户缺口(R-4)的解:
+ * 服务层看不到租户,所以「谁可以发起进件」这件事只能在**这一个同时握着新 {@code mch_no}
+ * 与认证租户的地方**定下来。
+ *
+ * <h2>★★ 租户归属闸(本任务的安全要点)</h2>
+ * <b>每个带 {@code mchNo} 的端点,第一行就是闸。</b>顺序固定为
+ * 「取认证上下文 → {@code requireByMchNoForTenant(mchNo, tenantId)} → 才调服务」——
+ * <b>绝不</b>先 {@code requireByMchNo(mchNo)} 再在 Java 里比对 {@code tenantId}:
+ * 那已经把别的租户的 PII 读进内存了({@code /api/merchant/info} 的应答里有身份证号、
+ * 法人证件号、真实姓名、手机号、银行卡照片 URL、通道费率 —— 见 spec §6.6.4)。
+ *
+ * <p><b>{@code tenantId} 只来自认证上下文</b>,取不到就**显式拒绝**(403),
+ * 不带着 {@code null} 继续往下走;也不接受请求体 / query 参数指定它(见
+ * {@link CreateMerchantRequest#tenantId} 与 {@link #requireTenantId()})。
+ *
+ * <p>★ <b>超管不继承拦截器的例外</b>:{@code TenantInnerInterceptor.ignoreTable} 对超管
+ * 直接返回 {@code true}(不追加谓词),但本控制器的端点**一律**要求非空租户 ——
+ * 代价是「超管账号的 {@code tenant_id} 若为 null 就拿不到本页」(响亮 403,不是静默越权)。
+ * 出口是照 {@code EnterpriseController.all} 的先例加一个显式的 {@code tenant_id} 参数,
+ * **本批次不实现**(Task 9 的进件页没有租户选择器,当前没有任何调用方会传它)。
+ *
+ * <h2>端点清单(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)</h2>
+ * <table border="1">
+ *   <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>
+ *       <td>{@link #requireTenantId()}(非空校验);行级过滤仍由 {@code TenantInnerInterceptor}
+ *           对 {@code pay_lsy_merchant} 追加(Step 0 第 2 行实测)</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/collection/onboarding/create}</td>
+ *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否(号由上游建出来)</td>
+ *       <td>{@link #requireTenantId()};归属由 {@code createOnboardingRow} 的第 1 个实参钉住</td></tr>
+ *   <tr><td>POST</td><td>{@code /payment/collection/onboarding/commit}</td>
+ *       <td>{@value #PERM_ONBOARDING_COMMIT}</td><td>是</td>
+ *       <td>{@code LsyMerchantService.requireByMchNoForTenant} —— 方法体的**第一行**之后立刻</td></tr>
+ * </table>
+ *
+ * <p>★ 权限串在注解里写成**字面量**(不是 {@code PERM_*} 常量拼接):全仓
+ * {@code grep 'module_payment:leshouying:onboarding:create'} 必须能命中 {@code @PreAuthorize}
+ * 那一行,Task 8 的菜单 SQL 要按字面量逐字对齐。两处的一致性由
+ * {@code LeshouyingOnboardingControllerTest.endpointPermissionStrings_matchTheDeclaredConstants}
+ * 的反射断言钉死(权限匹配是**全等**的,差一个字符即 403;而超管直通,所以用超管跑的用例
+ * 一个都测不出写错的权限串)。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/payment/collection/onboarding")
+@RequiredArgsConstructor
+public class LeshouyingOnboardingController {
+
+    /** 进件列表 / 查询。 */
+    static final String PERM_ONBOARDING_LIST = "module_payment:leshouying:onboarding:list";
+
+    /** 建号(上游创建商户 → 落本地行)。 */
+    static final String PERM_ONBOARDING_CREATE = "module_payment:leshouying:onboarding:create";
+
+    /** 提交入网信息(把商户资料提交给上游审核)。 */
+    static final String PERM_ONBOARDING_COMMIT = "module_payment:leshouying:onboarding:commit";
+
+    private final LsyMerchantService merchantService;
+    private final LsyOnboardingService onboardingService;
+
+    /**
+     * 商户映射分页(租户可见的进件记录)。
+     *
+     * <p>★ 本端点不带 {@code mchNo},但它同样是**以租户为范围**的读:一个没有租户的主体
+     * 在 {@code TenantInnerInterceptor} 下**不会被追加任何谓词**(Step 0 实测第 3 行),
+     * 拿到的是全平台所有租户的商户映射。所以它也过 {@link #requireTenantId()}。
+     *
+     * <p>★ 行级过滤仍由拦截器做({@code pay_lsy_merchant} 在 {@code CONDITIONAL_TABLES} 里,
+     * 已登录且 {@code tenantId != null} 时会被追加谓词)—— 这里**没有**再加一条显式谓词,
+     * 因为 {@code LsyMerchantService.page} 是既有方法、另有调用方({@code LeshouyingMerchantController}),
+     * 改它的行为会波及那个页面。**代价**:本端点的行级隔离仍然依赖那个配置在别处的拦截器,
+     * 这是本控制器唯一一处没有自带谓词的读 —— 已在报告中作为已知残留列出。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:list')")
+    @GetMapping("/page")
+    public Result<PageResult<LsyMerchantEntity>> page(
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "20") int pageSize,
+            @RequestParam(required = false) String keyword) {
+        requireTenantId();
+        return Result.ok(merchantService.page(pageNo, pageSize, keyword));
+    }
+
+    /**
+     * 建号:调上游创建商户 → 把**新商户落成本地行**。
+     *
+     * <p><b>顺序是有意的:先建号、后落行。</b>上游抛错时**一行都不写** ——
+     * 一个没有商户号的行比「没有行」更坏:Task 6 的审核通知腿按 {@code mch_no} 定位本地行,
+     * 它会命中一个永远收不到审核结果的壳,而那条腿的分支语义(找不到 ⇒ 让上游重推)
+     * 也就永远走不到。
+     *
+     * <p><b>为什么不复用 {@code LsyMerchantService.save}:</b>它的新增分支会把
+     * {@code review_status} 钉成 {@code APPROVED}(A 阶段手工维护存量行的口径),
+     * 而新商户是「还没审核」——落到那个值等于把「未审核」读成「审核过了」。
+     *
+     * <p>★ 已知窗口(**未做补偿**,本次按 brief 的顺序实现):上游建号成功、本地落行失败时,
+     * 上游多了一个商户而本地没有行 —— 与上面那条相反方向的半边。
+     * 上游调用无法纳入本地事务,所以这不是一个 {@code @Transactional} 能关掉的口子。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+    @PostMapping("/create")
+    public Result<LsyMerchantEntity> create(@RequestBody CreateMerchantRequest body) {
+        Long tenantId = requireTenantId();
+        rejectBodySuppliedTenantId(body);
+        // 建号:上游返回的商户号是**唯一**能让本地行与上游对上号的东西,且保证非空白(服务层 502 兜底)。
+        String mchNo = onboardingService.createMerchant(body);
+        return Result.ok(merchantService.createOnboardingRow(
+                tenantId, body.getEnterpriseId(), mchNo, body.getMerchantName()));
+    }
+
+    /**
+     * 提交商户入网信息(上游审核的入口)。
+     *
+     * <p>★ <b>本方法的第一件事就是租户闸</b>({@link #requireTenantId()} 之后立刻
+     * {@code requireByMchNoForTenant}):请求体里的 {@code mchNo} 是**外部输入**,
+     * 不先证明它属于当前租户就把它交给下游,等于允许任何一个登录用户拿别人的商户号
+     * 去走提交/查询那条链。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:commit')")
+    @PostMapping("/commit")
+    public Result<Void> commit(@RequestBody LsyCommitMerchantInfoDTO body) {
+        Long tenantId = requireTenantId();
+        merchantService.requireByMchNoForTenant(body.getMchNo(), tenantId);
+        onboardingService.commitMerchantInfo(body);
+        return Result.ok();
+    }
+
+    // ─────────────────────────────── 归属:只来自认证上下文 ───────────────────────────────
+
+    /**
+     * 取当前主体的租户;取不到就**显式拒绝**。
+     *
+     * <p>为什么不是「反正 {@code eq(列, null)} 在 SQL 里恒不成立、自然查不到」:
+     * 那是把安全性质挂在一个**未经本任务实测的 MyBatis-Plus 实现细节**上 ——
+     * 「恰好失败」与「设计上失败」在下次升级或换写法时分道扬镳,而本端点的测试不会因此变红。
+     *
+     * <p>取当前主体的既有惯用法照抄 {@code EnterpriseController.currentUser()}。
+     */
+    private Long requireTenantId() {
+        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = (auth != null && auth.getPrincipal() instanceof LoginUser u) ? u : null;
+        Long tenantId = user == null ? null : user.getTenantId();
+        if (tenantId == null) {
+            // 文案不含任何上游词汇(Global Constraint 1),且它经 GlobalExceptionHandler 原样回给调用方。
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法执行进件操作,请联系管理员");
+        }
+        return tenantId;
+    }
+
+    /**
+     * 请求体里出现 {@code tenant_id} ⇒ **拒绝**(而不是忽略)。
+     *
+     * <p>同 {@code LeshouyingMerchantController.save} 的既有教训(那里是
+     * {@code entity.setTenantId(null)} —— 归属不由调用方指定)。这里选拒绝的理由是
+     * 失败形态:忽略会让一个写错了这个字段的调用方拿到「成功」,并以为自己的指定生效了。
+     */
+    private static void rejectBodySuppliedTenantId(CreateMerchantRequest body) {
+        if (body.getTenantId() != null) {
+            throw new BusinessException(400, "归属租户只能由登录身份决定,请求体里不得出现 tenant_id");
+        }
+    }
+
+    /**
+     * 建号请求体 = 上游「创建商户」那 54 个字段({@link LsyMerchantCreateDTO})**加**本地的
+     * {@code enterprise_id}。
+     *
+     * <p>为什么要多这一个字段:{@code enterprise_id} 是 {@code pay_lsy_merchant} 的列
+     * (本地业务字段:这一行属于哪个企业),而 {@code LsyMerchantCreateDTO} 是**上游报文**的投影,
+     * 上游没有这个概念。新建的那一行必须带上它({@code uk_lsy_merchant_tenant_ent} 就建在
+     * {@code (tenant_id, enterprise_id)} 上);留空 ⇒ 写成 NULL,这一行即**租户级**映射
+     * (语义见 {@code LsyMerchantService.requireByTenant})。
+     *
+     * <p>★ 字段名随应用 ObjectMapper 的 {@code SNAKE_CASE} ⇒ 报文里的键是 {@code enterprise_id},
+     * 与上游那 54 个键同一套写法。
+     */
+    @Data
+    @EqualsAndHashCode(callSuper = true)
+    public static class CreateMerchantRequest extends LsyMerchantCreateDTO {
+
+        /** 本地业务字段(对应 {@code pay_lsy_merchant.enterprise_id})。空白 ⇒ NULL。 */
+        private String enterpriseId;
+
+        /**
+         * ★ <b>只为「响亮拒绝」而声明,从不被读取。</b>它在这里的意义是:请求体里塞了
+         * {@code tenant_id} 时,Jackson 不会把它当未知字段丢掉(本仓
+         * {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,那会变成 500「服务器内部错误」),
+         * 而是绑上来、由 {@link #rejectBodySuppliedTenantId} 当场拒成 400。
+         *
+         * <p>删掉它不会让归属变得可指定(归属只由 {@link #requireTenantId()} 决定),
+         * 只会把这个请求从「400 + 一句人话」降级成「500 服务器内部错误」。
+         * {@code LeshouyingOnboardingControllerTest.createEndpoint_rejectsBodySuppliedTenantId}
+         * 钉着这个行为。
+         */
+        private Long tenantId;
+    }
+}

+ 142 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -252,6 +252,148 @@ public class LsyMerchantService {
         return LsyReviewWriteOutcome.APPLIED;
     }
 
+    /**
+     * 按 {@code (tenant_id, mch_no)} 定位商户映射 —— <b>进件端点的租户归属闸</b>。
+     *
+     * <p>与 {@link #requireByMchNo} 是**两件事**:那个不带租户条件(免登录路径用,无认证上下文),
+     * 这个把租户**写进 WHERE**。已认证的管理端路径一律用本方法。
+     *
+     * <h2>为什么有了 {@code TenantInnerInterceptor} 还要这个方法</h2>
+     * 拦截器对 {@code pay_lsy_merchant}(在 {@code CONDITIONAL_TABLES} 里)的
+     * {@code ignoreTable} 行为 —— 2026-09-23 逐行**实测**(量法见下):
+     * <table border="1">
+     *   <caption>四行表(实测读数,不是照抄)</caption>
+     *   <tr><th>主体</th><th>{@code ignoreTable("pay_lsy_merchant")}</th>
+     *       <th>{@code getTenantId()}</th><th>重写后的 SQL</th></tr>
+     *   <tr><td>超管({@code isSuperuser=true})</td><td>{@code true}</td><td>1</td>
+     *       <td>{@code SELECT id FROM pay_lsy_merchant WHERE mch_no = ?} —— <b>不追加</b></td></tr>
+     *   <tr><td>已登录 {@code LoginUser} 且 {@code tenantId != null}</td><td>{@code false}</td><td>7</td>
+     *       <td>{@code … WHERE mch_no = ? AND tenant_id = 7} —— <b>自动追加</b></td></tr>
+     *   <tr><td>已登录 {@code LoginUser} 但 {@code tenantId == null}</td><td>{@code true}</td><td>0</td>
+     *       <td>不追加</td></tr>
+     *   <tr><td>无认证上下文 / principal 不是 {@code LoginUser}</td><td>{@code true}</td><td>0</td>
+     *       <td>不追加</td></tr>
+     * </table>
+     * <b>量法</b>:{@code new TenantInnerInterceptor()} 后取 {@code getTenantLineHandler()},
+     * 在四种 {@code SecurityContext} 下各调一次 {@code ignoreTable("pay_lsy_merchant")} 并读
+     * {@code getTenantId()};再用 {@code beforeQuery(...)} 把
+     * {@code SELECT id FROM pay_lsy_merchant WHERE mch_no = ?} 重写一遍读回 SQL(探针源码与
+     * 全部读数在 {@code .superpowers/sdd/2026-09-23-lsy-onboarding-B/task-7-report.md})。
+     *
+     * <p>⇒ 对**正常的已登录租户用户**(第 2 行),拦截器**其实已经**追加了租户谓词 ——
+     * 所以「{@link #requireByMchNo} 无租户条件于是越权」那个说法**不成立**。
+     * 本方法存在的理由换成了下面三条:
+     * <ol>
+     *   <li><b>第 3 行</b>({@code tenantId == null} 的已登录主体)与<b>第 1 行</b>(超管)
+     *       都**不追加**谓词。显式谓词让「不追加」不再等于「越权」;</li>
+     *   <li><b>隐性依赖</b>:拦截器是**在别处配置的**({@code CONDITIONAL_TABLES} 名单 +
+     *       {@code ignoreTable} 的分支)。把「这个查询是否会越权」的答案交给另一个文件,
+     *       等于让本端点的安全性质**可以被一次无关改动静默改掉,而本端点的测试不会变红**。
+     *       显式谓词把这条性质钉在本方法自己的 SET 里;</li>
+     *   <li><b>{@code selectOne} / {@code LIMIT 1} 的形状</b>:真库里
+     *       {@code mch_no='Y52211090004'} 有 2 行(同租户,见 {@link #requireByMchNo} 的
+     *       javadoc),{@code LIMIT 1} 会**静默取任意一行**。本方法用 {@code selectList}
+     *       自己判 0 / 1 / 多行(同 {@link #updateReviewStatusByMchNo} 那条形状)。</li>
+     * </ol>
+     *
+     * <h2>404 不构成跨租户探针</h2>
+     * 查询是**租户内**的,所以「查不到」与「存在但不属于本租户」在应答上**不可区分**
+     * (都是 404)—— 否则它就成了一个「这个商户号在平台里存不存在」的枚举口子。
+     *
+     * @param mchNo    上游发放的商户号(外部输入;空白 ⇒ 400)
+     * @param tenantId **必须来自认证上下文**。为 {@code null} ⇒ 403,
+     *                 不带着 null 往下走:{@code eq(列, null)} 的真实行为本次没实测过,
+     *                 而「恰好失败」与「设计上失败」在下次升级或换写法时分道扬镳
+     * @return 命中**恰好一行**时返回它
+     * @throws BusinessException 403 租户为空;400 商户号空白;404 本租户内查不到;
+     *                           409 本租户内命中多行(无法确定归属哪一行,**不静默取一行**)
+     */
+    public LsyMerchantEntity requireByMchNoForTenant(String mchNo, Long tenantId) {
+        // 租户闸先于一切:它是本方法的**契约**(公开 API),不依赖调用方先检查一遍。
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法查询商户,请联系管理员");
+        }
+        if (StrUtil.isBlank(mchNo)) {
+            throw new BusinessException(400, "商户号不能为空");
+        }
+
+        // ★ selectList,不是 selectOne / LIMIT 1 —— 见方法 javadoc 第 3 条。
+        List<LsyMerchantEntity> rows = merchantMapper.selectList(new LambdaQueryWrapper<LsyMerchantEntity>()
+                .eq(LsyMerchantEntity::getTenantId, tenantId)
+                .eq(LsyMerchantEntity::getMchNo, mchNo));
+
+        if (rows.isEmpty()) {
+            throw new BusinessException(404, "商户号不存在: " + mchNo);
+        }
+        if (rows.size() > 1) {
+            // 响亮失败,而不是挑一行。日志带命中的 id:那是人工处置(或用户裁定删哪一行)唯一能用的线索。
+            log.error("按 (租户, 商户号) 定位命中多行,拒绝继续(无法确定归属哪一行): "
+                            + "tenantId={}, mchNo={}, 命中 {} 行, ids={}",
+                    tenantId, LsyLogSanitizer.flatten(mchNo), rows.size(),
+                    rows.stream().map(LsyMerchantEntity::getId).toList());
+            throw new BusinessException(409,
+                    "该商户号在您的账户下不唯一,无法确定对应商户,请联系管理员处理");
+        }
+        return rows.get(0);
+    }
+
+    /**
+     * 进件的**建行入口**:把上游刚建出来的商户落成 {@code pay_lsy_merchant} 的一行。
+     *
+     * <p><b>为什么必须有这个方法:</b>{@code LsyOnboardingService.createMerchant} 只从上游换回一个
+     * 商户号,**它不写库**。而在本方法之前,{@code pay_lsy_merchant} 里没有任何一行带这个
+     * {@code mch_no} —— 于是 Task 6 的审核通知腿(按 {@code mch_no} 定位本地行,
+     * {@code updateReviewStatusByMchNo})**每一次**都走「找不到行」,上游的审核结果
+     * 永远写不进任何状态。落这一行的地方只能是**同时握着新 {@code mch_no} 与认证租户**的地方,
+     * 即本方法。
+     *
+     * <p><b>为什么不复用 {@link #save}:</b>它的新增分支会把 {@code review_status} 钉成
+     * {@code APPROVED}(A 阶段手工维护存量行的口径)与 {@code status='ENABLED'}。
+     * {@code APPROVED} 对进件新行是**错的值** —— 新商户还没送审,落成「已通过」等于把
+     * 「未审核」读成「审核过了」。所以这里直接 {@code insert},不复用 {@code save} 的任何分支。
+     *
+     * <p><b>{@code review_status} 写 {@link LsyMerchantReviewStatus#PENDING} 的 {@code getValue()}</b>
+     * (不是 {@code name()},也不是上游的码域 —— 三个枚举不要串,见该枚举的 javadoc),
+     * 明确**不落**建表语句的默认值 {@code 'APPROVED'}。
+     *
+     * <p><b>{@code tenant_id} 由实参传入、不靠自动填充:</b>{@code AutoFillMetaObjectHandler.insertFill}
+     * 在实体 {@code tenantId == null} 时会**从认证上下文里补一个** —— 那意味着一个漏传租户的
+     * 调用方会静默拿到「当前登录者的租户」而不是一个错误。所以这里先显式拦 {@code null},
+     * 再把值写上,让自动填充无从介入。
+     *
+     * <p>★ <b>本方法刻意不加 {@code @Transactional}</b>:只有一条 INSERT,事务不提供任何东西;
+     * 真正需要补偿的窗口在**上游建号成功、本地落行失败**那半边,
+     * 而那次上游调用不在任何本地事务的覆盖范围内。(该窗口未做补偿,已在报告中列出。)
+     *
+     * @param tenantId     **必须来自认证上下文**(为 {@code null} ⇒ 403)
+     * @param enterpriseId 本地业务字段(来自请求体)。空白 ⇒ NULL ⇒ 这一行是**租户级**映射
+     * @param mchNo        上游建号返回的商户号(空白 ⇒ 400;这是本地行与上游对号入座的唯一键)
+     * @param merchantName 商户名称(来自请求体)。空白 ⇒ NULL
+     * @return 落进去的那一行({@code id} 由 MyBatis-Plus 的 {@code ASSIGN_ID} 分配后写回实体)
+     */
+    public LsyMerchantEntity createOnboardingRow(Long tenantId, String enterpriseId, String mchNo,
+                                                 String merchantName) {
+        if (tenantId == null) {
+            throw new BusinessException(403, "当前登录身份没有归属租户,无法创建商户,请联系管理员");
+        }
+        if (StrUtil.isBlank(mchNo)) {
+            throw new BusinessException(400, "商户号不能为空");
+        }
+
+        LsyMerchantEntity row = new LsyMerchantEntity();
+        row.setTenantId(tenantId);
+        // 可空列:空 ⇒ NULL(口径见 LsyStrings)—— 存成空串的话任何 `IS NULL` 都查不到这一行,
+        // 而「租户级映射」找的正是 enterprise_id IS NULL。
+        row.setEnterpriseId(LsyStrings.blankToNull(enterpriseId));
+        row.setMchNo(mchNo);
+        row.setMerchantName(LsyStrings.blankToNull(merchantName));
+        // ★ 新行是「待审核」,不是建表语句默认的 APPROVED,也不是上游的码值。
+        row.setReviewStatus(LsyMerchantReviewStatus.PENDING.getValue());
+        row.setStatus("ENABLED");
+        merchantMapper.insert(row);
+        return row;
+    }
+
     public PageResult<LsyMerchantEntity> page(int pageNo, int pageSize, String keyword) {
         Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
         LambdaQueryWrapper<LsyMerchantEntity> w = new LambdaQueryWrapper<>();

+ 534 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -0,0 +1,534 @@
+package com.payment.platform.module.payment.leshouying.controller;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.fasterxml.jackson.databind.JavaType;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
+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.dto.LsyCommitMerchantInfoDTO;
+import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
+import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
+import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.AfterEach;
+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.annotation.AnnotatedElementUtils;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
+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.RequestMapping;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.*;
+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.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * 进件 HTTP 端点与租户归属闸(IDOR)。
+ *
+ * <p><b>【为什么本类里既有 mock 服务、又有一个真服务】</b>
+ * 有两个用例钉的是「**真的写了一行**」与「**查询真的带了租户谓词**」——
+ * 那两件事的载体是 {@link LsyMerchantService} 里的 wrapper 与实体,把服务 mock 掉就把它们一起
+ * mock 掉了({@code verify(merchantService).createOnboardingRow(...)} 只证明「调了这个方法」,
+ * 不证明「写进去的是 PENDING」)。那两个用例用 {@code new LsyMerchantService(mapperMock)}
+ * 显式装配一个真服务 —— 与 {@code LeshouyingOpenApiErrorContractTest} 里
+ * {@code new LsyQueryDetailAssembler(...)} 同一条理由。
+ *
+ * <p><b>【为什么未知字段的用例必须走 MockMvc】</b>请求体到实体的绑定发生在**进入方法体之前**;
+ * 直调 {@code controller.create(dto)} 根本不经过 Jackson,也就钉不住「请求体里的字段名长什么样」。
+ * 照 {@code LeshouyingOpenApiErrorContractTest} 的做法:真的 {@code RequestMappingHandlerAdapter}
+ * + **应用真实的 ObjectMapper**({@code JacksonConfig} 那个裸 {@code new ObjectMapper()})。
+ */
+@ExtendWith(MockitoExtension.class)
+class LeshouyingOnboardingControllerTest {
+
+    /** 认证上下文里的租户 —— 就是那个「归属只许来自这里」的值。 */
+    private static final Long TENANT = 7L;
+
+    /** 故意与 {@link #TENANT} 不同的数:请求体里塞它,用来证明它没被采纳。 */
+    private static final long BODY_TENANT = 999L;
+
+    private static final String MCH_NO = "Y52211090004";
+
+    /** 三个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
+    private static final String PREFIX = "/payment/collection/onboarding";
+
+    @Mock private LsyMerchantService merchantService;
+    @Mock private LsyOnboardingService onboardingService;
+
+    private LeshouyingOnboardingController controller;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        controller = new LeshouyingOnboardingController(merchantService, onboardingService);
+        ObjectMapper appMapper = new JacksonConfig().objectMapper();
+        mockMvc = MockMvcBuilders.standaloneSetup(controller)
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(appMapper))
+                .build();
+        // LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
+                LsyMerchantEntity.class);
+    }
+
+    @AfterEach
+    void clearContext() {
+        SecurityContextHolder.clearContext();
+    }
+
+    // ───────────────────────── 端点:归属只来自认证上下文 ─────────────────────────
+
+    /**
+     * ★ {@code tenantId} 只来自认证上下文:上下文里是 {@link #TENANT},落库用的就必须是它。
+     * 与 {@link #createEndpoint_rejectsBodySuppliedTenantId} 是**一对** ——
+     * 只钉「不接受请求体」而不钉「必须取上下文」,把 tenantId 恒写成 null 也能绿。
+     */
+    @Test
+    void createEndpoint_usesTenantFromSecurityContext() {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        controller.create(createBody("E1", "示例商户"));
+
+        verify(merchantService).createOnboardingRow(eq(TENANT), any(), eq(MCH_NO), any());
+    }
+
+    /**
+     * ★ 载荷级的活:建号成功后**真的落了一行**,且 {@code review_status} 是 {@code PENDING}。
+     *
+     * <p>不落库的话 Task 6 的审核通知腿(按 {@code mch_no} 定位本地行)**永远**走「找不到行」,
+     * 上游的审核结果写不进任何状态;落到建表语句默认的 {@code APPROVED} 则等于把「还没审核」
+     * 读成「审核过了」。
+     *
+     * <p>断言写在 {@code merchantMapper.insert} 的**实参**上(真服务 + mock mapper),
+     * 不是断言「调用了某个方法」—— 见类注释。
+     */
+    @Test
+    void createEndpoint_persistsRowWithPendingStatus() {
+        login(TENANT, false);
+        LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+        LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+
+        c.create(createBody("E1", "示例商户"));
+
+        ArgumentCaptor<LsyMerchantEntity> captor = ArgumentCaptor.forClass(LsyMerchantEntity.class);
+        verify(mapper).insert(captor.capture());
+        LsyMerchantEntity inserted = captor.getValue();
+
+        assertEquals("PENDING", inserted.getReviewStatus(),
+                "新行必须是待审核,不得落到建表语句的默认值 APPROVED。实测: " + inserted.getReviewStatus());
+        assertEquals(TENANT, inserted.getTenantId(), "tenant_id 只能来自认证上下文");
+        assertEquals("E1", inserted.getEnterpriseId(), "enterprise_id 来自请求体(业务字段)");
+        assertEquals(MCH_NO, inserted.getMchNo(), "mch_no 来自上游建号的返回值");
+        assertEquals("示例商户", inserted.getMerchantName());
+        assertNull(inserted.getId(), "主键由 MP 分配(ASSIGN_ID),调用方不得自带 id");
+    }
+
+    /**
+     * ★ 反向:上游抛错时**不得**留下半个行。
+     *
+     * <p>一个没有商户号的行比「没有行」更坏 —— 它会让 Task 6 那条按 {@code mch_no} 定位的
+     * 通知腿命中一个永远收不到审核结果的壳。所以顺序必须是**先建号、后落行**。
+     */
+    @Test
+    void createEndpoint_doesNotPersistRow_whenUpstreamThrows() {
+        login(TENANT, false);
+        LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+        LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
+        when(onboardingService.createMerchant(any()))
+                .thenThrow(new BusinessException(502, "上游返回成功但没有可用的商户号"));
+
+        assertThrows(BusinessException.class, () -> c.create(createBody("E1", "示例商户")));
+
+        verify(mapper, never()).insert(any());
+    }
+
+    /**
+     * ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
+     *
+     * <p>同 {@code LeshouyingMerchantController.save} 的既有教训(那里是
+     * {@code entity.setTenantId(null)} —— 归属不由调用方指定)。本端点选**拒绝**而不是**忽略**:
+     * 忽略会让写错了这个字段的调用方拿到「成功」,并以为自己的指定生效了。
+     *
+     * <p>本用例自带**正对照**:不带 {@code tenant_id} 的同形报文必须被正常受理 ——
+     * 否则那个「400」可能只是因为整份报文压根没绑上。
+     */
+    @Test
+    void createEndpoint_rejectsBodySuppliedTenantId() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        // 正对照:同样的报文、去掉 tenant_id ⇒ 受理
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+
+        // 反例:多一个 tenant_id ⇒ 拒绝(业务码 400,HTTP 200 —— 本仓 BusinessException 的既有出口)
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"enterprise_id\":\"E1\",\"tenant_id\":" + BODY_TENANT + "}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        verify(merchantService, never()).createOnboardingRow(eq(BODY_TENANT), any(), any(), any());
+        verify(merchantService, times(1)).createOnboardingRow(any(), any(), any(), any());
+    }
+
+    /**
+     * 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
+     *
+     * <p>三个端点一起钉:{@code page} 不带 {@code mchNo},但它同样是「以租户为范围」的读 ——
+     * 一个没有租户的主体在 {@code TenantInnerInterceptor} 下**不会被追加任何谓词**
+     * (Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
+     */
+    @Test
+    void everyEndpoint_rejectsPrincipalWithoutTenant_loudly() {
+        login(null, false);
+        for (Runnable call : allThreeEndpoints()) {
+            BusinessException ex = assertThrows(BusinessException.class, call::run,
+                    "已登录但 tenantId == null 的主体必须被拒(Step 0 表第 3 行)");
+            assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
+        }
+
+        SecurityContextHolder.clearContext();
+        for (Runnable call : allThreeEndpoints()) {
+            BusinessException ex = assertThrows(BusinessException.class, call::run,
+                    "无认证上下文同样必须被拒(Step 0 表第 4 行)");
+            assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
+        }
+
+        verifyNoInteractions(merchantService, onboardingService);
+    }
+
+    // ───────────────────────── 闸不依赖拦截器 ─────────────────────────
+
+    /**
+     * ★★ 本任务唯一能证明「**闸不依赖拦截器**」的用例。
+     *
+     * <p>主体取**超管** —— 它是 {@code TenantInnerInterceptor.ignoreTable} 的**第一个**分支
+     * ({@code isSuperuser == true ⇒ return true}),构造上**必定**不追加租户谓词。
+     *
+     * <p>两臂:
+     * <ol>
+     *   <li><b>超管 + 无显式租户 ⇒ 抛 403</b>。断言的是**异常码**,不是「查不到」——
+     *       后者在把租户校验删掉之后仍会绿({@code selectList} 返回什么完全由 mock 决定)。</li>
+     *   <li><b>超管 + 有显式租户 ⇒ 查询自带 {@code tenant_id = ?}</b>,且绑定的值就是传进去的那个。</li>
+     * </ol>
+     *
+     * <p>★ <b>本用例的诚实边界</b>:这是纯 Mockito 用例,**拦截器根本没有装配** ——
+     * 这正是要证明的:那条谓词由**被调方**构造,与拦截器在不在、返回什么无关。
+     * 拦截器自己的四行行为由 Step 0 的探针逐行实测(读数与量法在 {@code task-7-report.md}),
+     * 本用例不重复它、也不声称测过它。
+     */
+    @Test
+    void merchantQuery_carriesExplicitTenantPredicate_evenWhenInterceptorWouldSkip() {
+        LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+        LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
+
+        // 臂 ①:超管 + 无显式租户 ⇒ 显式拒绝,且一行都没查
+        login(null, true);
+        BusinessException ex = assertThrows(BusinessException.class, () -> c.commit(commitBody(MCH_NO)));
+        assertEquals(403, ex.getCode(),
+                "超管 + 无租户必须显式拒绝(403/400),不得「自然查不到」。实测: " + ex.getCode());
+        verifyNoInteractions(mapper);
+
+        // 臂 ②:同为主体超管,给出显式租户 ⇒ 查询仍自带 tenant_id 谓词
+        login(TENANT, true);
+        when(mapper.selectList(any())).thenReturn(List.of(row(MCH_NO)));
+        c.commit(commitBody(MCH_NO));
+
+        AbstractWrapper<?, ?, ?> w = capturedSelectList(mapper);
+        assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
+                "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
+        assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
+                "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
+    }
+
+    // ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
+
+    /**
+     * ★ 三串权限常量本身。
+     *
+     * <p>权限匹配是**全等**({@code PermissionCheck.hasAny} 用 {@code authorities::contains}),
+     * 差一个字符即 403;而**超管直通**,所以任何用超管跑的用例都测不出写错的权限串。
+     * 本用例是那处独立核对:字面量写死在测试里,与代码里的常量、与注解上的值逐字比。
+     *
+     * <p>★ 注解里用**字面量**而不是常量拼接:{@code grep 'module_payment:leshouying:onboarding:create'}
+     * 必须能命中 {@code @PreAuthorize} 那一行(Task 8 的菜单 SQL 要按字面量逐字对齐)。
+     * 代价是同一串在文件里出现两次 —— 于是下面那圈反射断言把「注解值 == 常量」钉死。
+     *
+     * <p>★ 本用例**不读** {@code db/} 下的 SQL 文件(有意如此):单测读 SQL 是把文件当接口,
+     * 脆且假;「SQL 与代码逐字一致」由 Task 8 的只读核对语句在库上验。
+     */
+    @Test
+    void endpointPermissionStrings_matchTheDeclaredConstants() {
+        assertEquals("module_payment:leshouying:onboarding:list",
+                LeshouyingOnboardingController.PERM_ONBOARDING_LIST);
+        assertEquals("module_payment:leshouying:onboarding:create",
+                LeshouyingOnboardingController.PERM_ONBOARDING_CREATE);
+        assertEquals("module_payment:leshouying:onboarding:commit",
+                LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
+
+        Set<String> declared = Set.of(
+                LeshouyingOnboardingController.PERM_ONBOARDING_LIST,
+                LeshouyingOnboardingController.PERM_ONBOARDING_CREATE,
+                LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
+
+        List<Method> mapped = mappedMethods();
+        assertEquals(3, mapped.size(),
+                "本控制器应恰好三个端点(三个权限串 → 三个端点,裁定 2)。实测: "
+                        + mapped.stream().map(Method::getName).toList());
+
+        Set<String> onAnnotations = new LinkedHashSet<>();
+        for (Method m : mapped) {
+            String value = annotationValue(m);
+            assertNotNull(value, m.getName() + " 缺 @PreAuthorize —— 端点会变成任何登录用户可调");
+            assertEquals(2, value.chars().filter(c -> c == '\'').count(),
+                    m.getName() + " 的 @PreAuthorize 引号数不对(应为成对的两个): " + value);
+            String literal = value.substring(value.indexOf('\'') + 1, value.lastIndexOf('\''));
+            assertEquals("@perm.hasAny('" + literal + "')", value,
+                    m.getName() + " 的 @PreAuthorize 不是 @perm.hasAny('…') 形态: " + value);
+            onAnnotations.add(literal);
+        }
+
+        assertEquals(declared, onAnnotations,
+                "注解上出现的权限串必须**恰好**是声明的三串 —— 多一串就是自行新增了权限串"
+                        + "(裁定 2:须报主控裁定,不得自增)。实测: " + onAnnotations);
+    }
+
+    /**
+     * 应答字段名白名单(Global Constraint 1:到达租户眼前的字符串不得带上游专有键名)。
+     *
+     * <p>判据(约束原文的两查):某个键名算「上游专有」,当且仅当
+     * ① 建表语句里没有这一列 **且** ② 有对外 DTO/实体把它回给租户。
+     * 于是白名单 = {@code Result} 信封 ∪ {@code PageResult} 分页壳 ∪ {@code LsyMerchantEntity}
+     * 的列名(都是**本地**词)。
+     *
+     * <p>两件事一起钉:
+     * <ol>
+     *   <li><b>结构性</b>:应答载荷类型不得是 {@code Map} / {@code Object} ——
+     *       那正是「把上游原始应答直接回给租户」的形状({@code LsyOnboardingService.merchantInfo}
+     *       返回的就是 {@code Map<String,Object>},里面有身份证号、法人证件号、银行卡照片 URL)。</li>
+     *   <li><b>字段名</b>:把每个端点应答类型的属性名(按应用 ObjectMapper 的 SNAKE_CASE 展开)
+     *       收成一个集合,断言它是白名单的**子集**。只写「不含某几个键」是黑名单,
+     *       漏一个上游键它照样绿。</li>
+     * </ol>
+     */
+    @Test
+    void noEndpointReturnsUpstreamOnlyFields() {
+        ObjectMapper appMapper = new JacksonConfig().objectMapper();
+
+        Set<String> allowed = new LinkedHashSet<>(List.of(
+                // Result 信封
+                "code", "msg", "data", "status_code", "success",
+                // PageResult 分页壳
+                "page_no", "page_size", "total", "has_next", "list",
+                // pay_lsy_merchant 的列(V1.8 建表语句)
+                "id", "tenant_id", "enterprise_id", "mch_no", "shop_no", "merchant_name",
+                "review_status", "status", "description", "created_time", "updated_time"));
+
+        Set<String> seen = new TreeSet<>();
+        for (Method m : mappedMethods()) {
+            Type payload = payloadType(m);
+            Class<?> raw = rawClass(payload);
+            assertFalse(Map.class.isAssignableFrom(raw),
+                    m.getName() + " 直接返回 Map —— 上游原始应答(含 PII)不得回给租户。实测: " + raw);
+            assertNotEquals(Object.class, raw, m.getName() + " 的载荷类型是 Object(同上)");
+            // ★ 收的是**整个**泛型返回类型(Result<…> 连同里面的 PageResult 壳与元素),
+            //   不是剥完信封那一层 —— 剥掉的那两层(信封 / 分页壳)同样会进应答体。
+            seen.addAll(propertyNames(appMapper,
+                    appMapper.getTypeFactory().constructType(m.getGenericReturnType())));
+        }
+
+        // 正对照:反射这条链要么收全了、要么什么都没收到 —— 空集合会让「子集」恒真
+        assertTrue(seen.contains("mch_no") && seen.contains("review_status") && seen.contains("list"),
+                "应答字段名没收全,白名单断言已失效。实测: " + seen);
+        assertTrue(seen.size() >= 15, "收集到的字段名太少(" + seen.size() + ")。实测: " + seen);
+
+        Set<String> extra = new TreeSet<>(seen);
+        extra.removeAll(allowed);
+        assertEquals(Set.of(), extra,
+                "应答里出现了本地词表之外的字段名(Global Constraint 1)。实测多出: " + extra);
+    }
+
+    // ─────────────────────────────── 反射工具 ───────────────────────────────
+
+    /**
+     * 本控制器自己声明的、带请求映射的方法。
+     * ★ **不以「有没有 {@code @PreAuthorize}」为筛选条件** —— 否则漏加注解的方法会被静默排除,
+     * 而「每个端点都必须有权限注解」正是要钉的东西。
+     */
+    private static List<Method> mappedMethods() {
+        List<Method> out = new ArrayList<>();
+        for (Method m : LeshouyingOnboardingController.class.getDeclaredMethods()) {
+            if (AnnotatedElementUtils.findMergedAnnotation(m, RequestMapping.class) != null) {
+                out.add(m);
+            }
+        }
+        out.sort(java.util.Comparator.comparing(Method::getName));
+        return out;
+    }
+
+    private static String annotationValue(Method m) {
+        PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(m, PreAuthorize.class);
+        return a == null ? null : a.value();
+    }
+
+    /** {@code Result<X>} / {@code Result<PageResult<X>>} 里的 X(剥掉信封)。 */
+    private static Type payloadType(Method m) {
+        Type t = typeArgument(m.getGenericReturnType());
+        if (rawClass(t) == PageResult.class) {
+            t = typeArgument(t);
+        }
+        return t;
+    }
+
+    private static Type typeArgument(Type t) {
+        if (t instanceof ParameterizedType p && p.getActualTypeArguments().length == 1) {
+            return p.getActualTypeArguments()[0];
+        }
+        throw new IllegalStateException("应答类型不是单参数泛型,本工具解不了: " + t);
+    }
+
+    private static Class<?> rawClass(Type t) {
+        if (t instanceof Class<?> c) {
+            return c;
+        }
+        if (t instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) {
+            return c;
+        }
+        throw new IllegalStateException("解不出原始类型: " + t);
+    }
+
+    /**
+     * 用**应用真实的** ObjectMapper 展开属性名(SNAKE_CASE),并**递归下沉到嵌套的 bean**
+     * ({@code Result.data} 是 {@code PageResult},{@code PageResult.list} 的元素是实体 ——
+     * 这三层的键都会进应答体,少收一层就等于给那一层开了后门)。
+     * JDK 自带的标量类型(String / 数值 / 时间 / 枚举)不再下沉。
+     */
+    private static Set<String> propertyNames(ObjectMapper mapper, JavaType jt) {
+        return propertyNames(mapper, jt, 0);
+    }
+
+    private static Set<String> propertyNames(ObjectMapper mapper, JavaType jt, int depth) {
+        Set<String> out = new LinkedHashSet<>();
+        if (jt == null || depth > 4 || isJdkScalar(jt)) {
+            return out;
+        }
+        for (var p : mapper.getSerializationConfig().introspect(jt).findProperties()) {
+            out.add(p.getName());
+            JavaType pt = p.getPrimaryType();
+            if (pt == null) {
+                continue;
+            }
+            if (Collection.class.isAssignableFrom(pt.getRawClass()) && pt.getContentType() != null) {
+                out.addAll(propertyNames(mapper, pt.getContentType(), depth + 1));
+            } else {
+                out.addAll(propertyNames(mapper, pt, depth + 1));
+            }
+        }
+        return out;
+    }
+
+    private static boolean isJdkScalar(JavaType jt) {
+        Class<?> c = jt.getRawClass();
+        if (c.isPrimitive() || c.isEnum() || c.isArray()) {
+            return true;
+        }
+        String n = c.getName();
+        return n.startsWith("java.") || n.startsWith("javax.") || n.startsWith("jakarta.");
+    }
+
+    // ─────────────────────────────── 夹具 ───────────────────────────────
+
+    private List<Runnable> allThreeEndpoints() {
+        return List.of(
+                () -> controller.page(1, 20, null),
+                () -> controller.create(createBody("E1", "示例商户")),
+                () -> controller.commit(commitBody(MCH_NO)));
+    }
+
+    /** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
+    private LeshouyingOnboardingController controllerWithRealMerchantService(LsyMerchantMapper mapper) {
+        return new LeshouyingOnboardingController(new LsyMerchantService(mapper), onboardingService);
+    }
+
+    private static AbstractWrapper<?, ?, ?> capturedSelectList(LsyMerchantMapper mapper) {
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Wrapper<LsyMerchantEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
+        verify(mapper).selectList(captor.capture());
+        return (AbstractWrapper<?, ?, ?>) captor.getValue();
+    }
+
+    private static LeshouyingOnboardingController.CreateMerchantRequest createBody(
+            String enterpriseId, String merchantName) {
+        LeshouyingOnboardingController.CreateMerchantRequest b =
+                new LeshouyingOnboardingController.CreateMerchantRequest();
+        b.setEnterpriseId(enterpriseId);
+        b.setMerchantName(merchantName);
+        return b;
+    }
+
+    private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
+        LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
+        d.setMchNo(mchNo);
+        return d;
+    }
+
+    private static LsyMerchantEntity row(String mchNo) {
+        LsyMerchantEntity r = new LsyMerchantEntity();
+        r.setId(1L);
+        r.setTenantId(TENANT);
+        r.setMchNo(mchNo);
+        r.setReviewStatus("PENDING");
+        return r;
+    }
+
+    /** 把当前主体放进 SecurityContext。{@code tenantId == null} 是 Step 0 表第 3 行的主体。 */
+    private static void login(Long tenantId, boolean isSuperuser) {
+        LoginUser u = new LoginUser();
+        u.setUserId(1L);
+        u.setUsername("tester");
+        u.setStatus("0");
+        u.setTenantId(tenantId);
+        u.setIsSuperuser(isSuperuser);
+        u.setAuthorities(List.of());
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(u, null, List.of()));
+    }
+}

+ 126 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -179,6 +179,132 @@ class LsyMerchantServiceTest {
                 "租户条件的占位符应解析成入参 tenantId。实测: " + w.getParamNameValuePairs());
     }
 
+    // ─────────────────── requireByMchNoForTenant(进件端点的租户归属闸,Task 7) ───────────────────
+
+    /**
+     * ★ 查询条件里**有** {@code tenant_id},且占位符绑定的是入参那个租户。
+     *
+     * <p>形状照 {@link #requireByTenant_queriesNullEnterpriseId}:条件集相等 + 逐条点名。
+     * 不用 {@code contains("tenant_id")} —— {@code tenant_id} 是 {@code pay_lsy_merchant} 的列名,
+     * 但它同时是 {@code WHERE} 里唯一一个「本方法自己加上去」的条件,
+     * 必须有**恰一条**而不是「出现过」。
+     */
+    @Test
+    void requireByMchNoForTenant_queriesWithTenantCondition() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
+
+        service.requireByMchNoForTenant("Y52211090004", 7L);
+
+        AbstractWrapper<?, ?, ?> w = capturedSelectList();
+        String seg = w.getSqlSegment();
+        // 实测形状:整个条件组被一对括号包着(`(a = ? AND b = ?)`),先剥掉再按 AND 切。
+        // 不要用 contains(列名) 判——见本用例的 javadoc。
+        Set<String> conds = Arrays.stream(seg.replaceAll("[()]", "").split(" AND "))
+                .map(String::trim).collect(Collectors.toSet());
+
+        assertEquals(2, conds.size(), "条件应恰好两条(租户 + 商户号)。实测: [" + seg + "] ⇒ " + conds);
+        assertEquals(1, conds.stream().filter(c -> c.startsWith("tenant_id = ")).count(),
+                "必须恰有一条租户条件(少了会跨租户串号)。实测条件: " + conds);
+        assertTrue(conds.stream().anyMatch(c -> c.startsWith("mch_no = ")),
+                "必须按 mch_no 定位。实测条件: " + conds);
+
+        assertEquals(7L, boundPredicateValue(w, "tenant_id"),
+                "租户条件的占位符应绑定入参 tenantId。实测: " + w.getParamNameValuePairs());
+        assertEquals("Y52211090004", boundPredicateValue(w, "mch_no"),
+                "商户号条件的占位符应绑定入参 mchNo。实测: " + w.getParamNameValuePairs());
+    }
+
+    /**
+     * ★★ IDOR 负向:租户 B 拿租户 A 的 {@code mch_no} ⇒ 404,且**不得**回任何 A 的数据。
+     *
+     * <p>本用例的强度**不来自 mock 的返回值**,而来自查询条件本身:条件里带着
+     * {@code tenant_id = 7},所以「A 的行」在**构造上**不可能出现在结果集里 ——
+     * mock 返回空列表只是把这个事实演出来。
+     *
+     * <p>★ 另一条同样重要的性质:这个 404 **不构成跨租户探针**。查询是租户内的,
+     * 「查不到」与「存在但不属于你」在应答上**不可区分** —— 否则它就成了一个
+     * 「这个商户号在平台里存不存在」的枚举口子。
+     */
+    @Test
+    void requireByMchNoForTenant_otherTenantsMchNo_throws404() {
+        // 站在租户 7:库里那两行都属于租户 2 ⇒ 带租户谓词的查询命中 0 行
+        when(merchantMapper.selectList(any())).thenReturn(List.of());
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.requireByMchNoForTenant("Y52211090004", 7L));
+
+        assertEquals(404, ex.getCode(), "越权探测必须与「不存在」不可区分。实测: " + ex.getCode());
+        assertFalse(ex.getMessage().contains("APPROVED"),
+                "应答不得回任何别租户行的内容。实测: " + ex.getMessage());
+        assertEquals(7L, boundPredicateValue(capturedSelectList(), "tenant_id"),
+                "哪怕是查不到,谓词也必须打在这个租户上(不能先查全表再在 Java 里比)");
+    }
+
+    /**
+     * ★★ 同租户内 {@code mch_no} 命中多行 ⇒ **响亮失败**,绝不静默取一行。
+     *
+     * <p>这**不是理论情形**:真库里 {@code mch_no='Y52211090004'} 就有 <b>2</b> 行,
+     * 同租户、{@code enterprise_id} 一行有值一行 NULL,**两行各自都合法**
+     * (分别落在 {@code uk_lsy_merchant_tenant_ent} 与 {@code uk_lsy_merchant_tenant_level} 上)。
+     * 唯一索引 {@code uk_lsy_merchant_mch_no} 今天不存在、且因这两行**建不起来**,
+     * 删哪一行是用户的数据决策 ⇒ 这个状态可能长期存在。
+     *
+     * <p>所以:0 行 ⇒ 404;&gt;1 行 ⇒ **抛**(且 ERROR 日志里带命中的 id,那是人工处置的唯一线索)。
+     * 用 mock 构造,不依赖库里的数据形状 ⇒ 无论索引建没建、库里有没有重复,本用例强度不变。
+     */
+    @Test
+    void requireByMchNoForTenant_duplicateMchNoSameTenant_failsLoudly() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                rowWithReviewStatus(1L, "APPROVED"),
+                rowWithReviewStatus(2102313081860202497L, "APPROVED")));
+
+        List<String> logs = logsOf(() -> {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.requireByMchNoForTenant("Y52211090004", 2L));
+            assertNotEquals(404, ex.getCode(),
+                    "多行不是「不存在」—— 两者的处置完全不同(一个改数据、一个查配置)");
+        });
+
+        String all = String.join("\n", logs);
+        assertTrue(all.contains("2102313081860202497"),
+                "多行时 ERROR 日志必须带上命中的 id(人工处置唯一线索)。实测日志: " + all);
+        assertTrue(all.contains("Y52211090004"), "日志须带商户号。实测日志: " + all);
+    }
+
+    /**
+     * 闸自己也要拦 {@code tenantId == null}:**不得**带着 null 往下走,
+     * 更不得指望「{@code eq(列, null)} 在 SQL 里恒不成立、自然查不到」——
+     * 那是把安全性质挂在一个未经实测的 MyBatis-Plus 实现细节上,而「恰好失败」
+     * 与「设计上失败」在下次升级或换写法时分道扬镳。
+     *
+     * <p>控制器也会先拒一次(两处都拒是有意的纵深防御:本方法是**公开 API**,
+     * 它的契约必须自持,不能依赖调用方先检查)。
+     */
+    @Test
+    void requireByMchNoForTenant_nullTenant_throws403() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.requireByMchNoForTenant("Y52211090004", null));
+
+        assertEquals(403, ex.getCode(), "实测: " + ex.getCode());
+        verify(merchantMapper, never()).selectList(any());
+    }
+
+    /**
+     * 落进件那一行的写入口也要拦 {@code tenantId == null},理由同上。
+     *
+     * <p>★ 这条尤其不能省:{@code AutoFillMetaObjectHandler.insertFill} 在实体
+     * {@code tenantId == null} 时会**从认证上下文里补一个** —— 于是一个漏传租户的调用方
+     * 会静默拿到「当前登录者的租户」,而不是一个错误。显式拦掉,让这件事当场可见。
+     */
+    @Test
+    void createOnboardingRow_nullTenant_throws403() {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.createOnboardingRow(null, "E1", "Y52211090004", "示例商户"));
+
+        assertEquals(403, ex.getCode(), "实测: " + ex.getCode());
+        verify(merchantMapper, never()).insert(any());
+    }
+
     // ─────────────────────── save():编辑分支(D-1 / D-2 / 0 行判据) ───────────────────────
 
     @Test