Răsfoiți Sursa

feat(乐收赢): 商户号改成超管代录 —— 门禁换成身份,归属改为必填

用户裁定:「新增商户号是超管操作,需要选择租户,租户不允许自己录入商户号」。
在此之前 `save` / `delete` 与 `page` 共用一条权限串 `module_payment:leshouying:merchant:list`
(承租户也能新增与删除),且 `save` 里有一行 `entity.setTenantId(null)` —— 归属只能由
认证上下文决定,因为**任何登录用户**都能走到那里。

## 门禁:为什么必须是**身份**而不是权限串

只有超管 bypass 了 `TenantInnerInterceptor.ignoreTable`(`LoginUser.getIsSuperuser()` 为真
时它整表跳过),别的角色没有。而一个被权限串放行的非超管调用方可以**指定任意 tenantId**,
insert 时既不会被改写成自己的租户、也不会报错:实测 MyBatis-Plus 3.5.5,tenantId 带 insert fill
⇒ 列清单无条件含 `tenant_id` ⇒ 租户拦截器的追加分支被 `ignoreInsert` 短路。
⇒ 那等于把「替任意租户建号」开给任何拿到该串的角色,而这一行决定的是**钱进哪个乐收赢商户号**。

所以 `save` / `delete` 改成 `@PreAuthorize("@perm.hasRole('SUPERUSER')")`,
`page` 保持权限串不动 —— 租户本来就该看得见自己那几行(还要拿它排查「未配置商户号」),
而查询本身被租户拦截器钉在自己的租户内。

## 归属:两处闸,管的是两件事

- 门禁(只许超管调用)在控制器注解上;
- `LsyMerchantService#save` 新增分支:`tenantId == null` ⇒ 400「请选择租户」。
  少了这一条不会报错,而是**静默**落到超管自己那个租户上 —— 超管 bypass 了拦截器,
  insert 既不被改写也不报错,只剩 `AutoFillMetaObjectHandler` 从 `LoginUser` 填值。

编辑分支**一律忽略**请求体里的 `tenant_id`:SET 列表是 5 个业务列的字面枚举,
改归属在**结构上**不成立。不额外读一次旧行比对后报 400 —— 那会往编辑分支插一次
`selectById`,而 `save_updateBranch_zeroAffectedRows_throws404` 明确钉着「0 行路径不得回读」。

刻意**不**把 `tenant_id` 从实体上摘掉:请求体里出现它是合法的(新增时靠它),
只是编辑分支不消费。

## 测试

`LeshouyingAdminWiringTest` 的腿③原先按**字面量形状**分类(大写且无冒号 = 角色),
那会把一个真写错的大写权限串判成绿的。改成按**它出现在哪个 SpEL 调用里**分类:
`hasRole(...)` 的实参是角色名,其余都是权限串。
- 反空转的断言挪到**全部**引号字面量上(一个都没有 ⇒ 红);
- 新增腿③之三:管理端角色门禁只许 `SUPERUSER`,比较前统一补 `ROLE_` 前缀
  (`hasRole('ROLE_SUPERUSER')` 是文档里的等价写法,不该判红);
- 新增 `merchantWriteEndpoints_areGatedBySuperuserIdentity_notByAPermissionString`,
  逐个方法钉住这两个端点的门禁形状。

两向变异 7 个变异体全部符合预期(M3 是把 `hasRole('SUPERUSER')` 写成
`hasRole('ROLE_SUPERUSER')` 的等价改写 ⇒ 实测**仍绿**,这是对照组)。
alphaH 2 săptămâni în urmă
părinte
comite
5db0155b39

+ 37 - 11
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java

@@ -13,8 +13,17 @@ import org.springframework.web.bind.annotation.*;
  * 乐收赢商户映射管理 —— 走 JWT 认证(不在 ApiKey 白名单)。
  * A 阶段联调必需:不配 mch_no 就无法下单。
  *
- * <p>权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致
- * (PermissionCheck.hasAny 是全等匹配,差一个字符即 403)。
+ * <p>★ 本类**两种门禁并存**,别照着其中一个去改另一个:
+ * <ul>
+ *   <li>{@link #page} 是**权限串** {@code module_payment:leshouying:merchant:list} ——
+ *       租户本来就该看得见自己那几行(还要拿它排查「未配置商户号」),
+ *       而查询本身被 {@code TenantInnerInterceptor} 钉在自己的租户内;</li>
+ *   <li>{@link #save} / {@link #delete} 是**超管身份** {@code @perm.hasRole('SUPERUSER')}
+ *       —— 见那两个方法的 javadoc(2026-09-30 用户裁定)。</li>
+ * </ul>
+ * 权限串与 Task 18 的 sys_menu.permission 是**跨任务契约**,须逐字一致
+ * (PermissionCheck.hasAny 是全等匹配,差一个字符即 403)。角色名不在任何菜单 SQL 里 ——
+ * 契约比对那一侧由 {@code LeshouyingAdminWiringTest} 按「出现在哪个调用里」把两者分开。
  */
 // 总开关:leshouying.enabled=false 时本类不装配(端点不存在),见 LeshouyingProperties#enabled
 @ConditionalOnProperty(name = "leshouying.enabled", havingValue = "true", matchIfMissing = true)
@@ -35,23 +44,40 @@ public class LeshouyingMerchantController {
     }
 
     /**
-     * 新增/修改商户映射。
+     * 新增/修改商户映射。**只有超管**(2026-09-30 用户裁定:
+     * 「新增商户号是超管操作,需要选择租户,租户不允许自己录入商户号」)。
      *
-     * <p>★ 归属只能由认证上下文决定,**不接受调用方指定** —— 故显式置 null。
-     * 不置的话:请求体里的 {@code tenantId} 会被原样写库(AutoFillMetaObjectHandler 只在值为
-     * null 时才填,而 LsyMerchantService.save 零租户校验)⇒ 租户 B 可以写出一行
-     * **属于租户 A、但 mch_no 由 B 指定**的映射,之后 A 下单的款项就进了 B 的乐收赢商户号。
+     * <p>★ 这里**故意不用权限串**:权限串的语义是「授给哪个角色、哪个角色就能调」,
+     * 而本端点的约束是**身份**。差别是实质的,不是措辞 —— 只有超管 bypass 了
+     * {@code TenantInnerInterceptor},别的角色没有;一个被权限串放行的非超管调用方可以
+     * **指定任意 tenantId**,而 insert 时既不会被改写成自己的租户、也不会报错
      * (实测 MyBatis-Plus 3.5.5:tenantId 带 insert fill ⇒ 列清单无条件含 tenant_id ⇒
-     * 租户拦截器的追加分支被 ignoreInsert 短路 ⇒ 既不改写也不报错,越权是**静默**的。)
+     * 租户拦截器的追加分支被 ignoreInsert 短路)。⇒ 那等于把「替任意租户建号」开给了
+     * 任何拿到该串的角色,而这一行决定的是钱进哪个乐收赢商户号。
+     * 同形状的第一个实例是 {@code HarMerchantController#save}(同一条裁定)。
+     * 改动注解之前先看 {@code LeshouyingAdminWiringTest#merchantWriteEndpoints_…}:
+     * 它逐个方法钉住这两个端点的门禁形状。
+     *
+     * <p>★ 归属({@code tenant_id})**由请求体给出**,语义在
+     * {@code LsyMerchantService#save}:新增必填(缺即 400「请选择租户」),编辑一律忽略
+     * (改归属在结构上不成立)。这与改动前正好相反 —— 那时本方法把 {@code tenantId}
+     * 显式置 null,理由是**任何登录用户**都能走到这里。
+     * 换掉那个写法的前提就是上面这道身份门禁,两者必须同时成立:
+     * 谁把门禁改回权限串,这个字段立刻变回一个跨租户写入的原语。
      */
-    @PreAuthorize("@perm.hasAny('module_payment:leshouying:merchant:list')")
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
     @PostMapping
     public Result<LsyMerchantEntity> save(@RequestBody LsyMerchantEntity entity) {
-        entity.setTenantId(null);   // 归属只能由认证上下文决定,不接受调用方指定
         return Result.ok(merchantService.save(entity));
     }
 
-    @PreAuthorize("@perm.hasAny('module_payment:leshouying:merchant:list')")
+    /**
+     * 删除商户映射。**只有超管**,理由同 {@link #save}(删掉等于让该租户无法下单)。
+     *
+     * <p>超管 bypass 租户拦截器 ⇒ {@code deleteById} 删得到任意租户的那一行;
+     * 非超管根本走不到这里(403 在进入方法之前就发生了)。
+     */
+    @PreAuthorize("@perm.hasRole('SUPERUSER')")
     @DeleteMapping("/{id}")
     public Result<Void> delete(@PathVariable Long id) {
         merchantService.delete(id);

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

@@ -506,6 +506,21 @@ public class LsyMerchantService {
      * 列表里没有它们;本方法在这两列上唯一的写点是新增分支的默认值补全。B 阶段进件成功后写这一行、
      * 以及乐收赢审核通知回写 {@code review_status},走 B 阶段自己的写入口(届时的方法或 mapper 调用),
      * 不要复用本方法 —— 它的编辑分支写不了这两列,返回值又与正常成功无法区分:回读行里这两列仍是库里的旧值。
+     *
+     * <p><b>归属({@code tenant_id},2026-09-30)</b>:新增时**必填**,缺即 400
+     * (理由见下面新增分支里的注释);编辑时**一律忽略**请求体里的这个值 ——
+     * 编辑分支的 SET 列表是 5 个业务列的字面枚举,{@code tenant_id} 不在其中,
+     * 所以改归属在**结构上**不成立(由
+     * {@code LsyMerchantServiceTest#save_updateBranch_writesExactlyTheSixOwnedColumns}
+     * 的 {@code assertFalse(cols.contains("tenant_id"))} 与
+     * {@code save_updateBranch_tenantFromBody_neverReachesTheSetClause} 两处钉住)。
+     * <b>不</b>额外读一次旧行、比对后报 400:那会往编辑分支插一次 {@code selectById},
+     * 而 {@code save_updateBranch_zeroAffectedRows_throws404} 明确钉着「0 行路径不得回读」。
+     * 界面那一侧还有一道 —— 编辑态的租户下拉是置灰的。
+     *
+     * <p>★ 本方法**不判身份**(「你是不是超管」):服务层没有认证上下文,
+     * 门禁在 {@code LeshouyingMerchantController#save} 的
+     * {@code @PreAuthorize("@perm.hasRole('SUPERUSER')")} 上。
      */
     @Transactional
     public LsyMerchantEntity save(LsyMerchantEntity entity) {
@@ -531,6 +546,16 @@ public class LsyMerchantService {
         entity.setDescription(LsyStrings.blankToNull(entity.getDescription()));
 
         if (entity.getId() == null) {
+            // ★ 归属必填(2026-09-30):录入商户号已从「租户自助」改成**超管代录**,
+            //   替哪个租户建号必须由操作者说清楚。少了这一条不会报错,而是**静默**落到
+            //   超管自己那个租户上 —— 超管 bypass 了 TenantInnerInterceptor.ignoreTable,
+            //   于是 insert 既不被改写也不报错,只剩 AutoFillMetaObjectHandler 从 LoginUser 填值,
+            //   而这一行决定的是**钱进哪个乐收赢商户号**。
+            //   门禁(只许超管调用)在 LeshouyingMerchantController#save 的注解上,
+            //   本行管的是另一件事:**归属说清楚了没有**。两处都要有,理由见那个方法的 javadoc。
+            if (entity.getTenantId() == null) {
+                throw new BusinessException(400, "请选择租户");
+            }
             // ★ 默认值只在新增分支补 —— 见方法注释
             if (StrUtil.isBlank(entity.getReviewStatus())) {
                 entity.setReviewStatus("APPROVED");   // A 阶段手工维护,默认视为已审核

+ 145 - 3
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingAdminWiringTest.java

@@ -70,9 +70,33 @@ class LeshouyingAdminWiringTest {
     private static final String OPENAPI_SUB_PATH = "/payment/openapi/pay/order/query";
     private static final String OPENAPI_BARE_PREFIX = "/payment/openapi/pay";
 
+    /**
+     * {@code ROLE_} 前缀规范化之后的超管权限名 —— 被守的控制器里**唯一**合法的角色门禁。
+     *
+     * <p>串的来路(按符号定位,不是推断):{@code UserDetailsServiceImpl:72}
+     * 用 {@code new SimpleGrantedAuthority("ROLE_SUPERUSER")} 装配;{@code PermissionCheck}
+     * 的 {@code hasAny/hasAll} 也按这一串短路放行。
+     *
+     * <p>写成带前缀的形态,是因为比较前会先按 {@code PermissionCheck.hasRole} 的规则规范化
+     * (缺 {@code ROLE_} 就补上)—— 两种等价写法都收,免得一次等价改写变成假红。
+     */
+    private static final String ROLE_SUPERUSER_AUTHORITY = "ROLE_SUPERUSER";
+
+    /**
+     * {@code @bean.hasRole('X')} 这个调用形状。
+     *
+     * <p>★ 认的是**调用的方法名**,不是字面量的形状:拿「大写、无冒号」当角色名的判据,
+     * 会把一个**真写错的权限串**(大写)当成角色名放过去 —— 那是把一条该红的错法洗成绿的。
+     * 宽松处理空白({@code @perm . hasRole ( 'X' )})是刻意的:SpEL 是字符串,重排空白是合法改动。
+     */
+    private static final Pattern ROLE_CALL =
+            Pattern.compile("@[A-Za-z_$][A-Za-z0-9_$]*\\s*\\.\\s*hasRole\\s*\\(\\s*'([^']*)'");
+
     private static final String[] ADMIN_PATHS = {
             "/payment/collection/order/page",
             "/payment/collection/order/{outTradeNo}",
+            // 2026-09-30 追加:本通道第一个**动钱**的管理端端点,更不能被挂到 API Key 身份上。
+            "/payment/collection/order/{outTradeNo}/refund",
             "/payment/collection/refund/page",
             "/payment/collection/refund/{outRefundNo}",
             "/payment/collection/merchant/page",
@@ -107,10 +131,16 @@ class LeshouyingAdminWiringTest {
      * **落在契约清单里**」。清单只扫 `lsy-menu.sql` 时,`cashier` 那三条串会全部被判成
      * 「授不出来的野串」—— 也就是**新控制器一登记就必然变红**。
      * 反过来说:漏扫一份脚本的后果是**假红**(不是假绿),这个方向是安全的。
+     *
+     * <p>★ 2026-09-30 追加第三份 `db/lsy-order-refund-menu.sql`(管理端退款按钮的权限点)。
+     * 那份脚本单独立文件(不并进 `lsy-menu.sql`)的理由与汇安融那批相同:
+     * `lsy-menu.sql` 是「全新库安装」用的版本、且**已在线上执行过** ——
+     * 往里面补一行对现有库**不会生效**(目录守卫命中后整段不插且不报错)。
      */
     private static final String[] MENU_SQLS = {
             "db/lsy-menu.sql",
             "db/lsy-cashier-menu.sql",
+            "db/lsy-order-refund-menu.sql",
     };
 
     @Test
@@ -314,6 +344,7 @@ class LeshouyingAdminWiringTest {
         }
 
         Set<String> declaredPermissions = new LinkedHashSet<>();
+        Set<String> roles = new LinkedHashSet<>();
         for (Map.Entry<Class<?>, List<Method>> entry : discovered.entrySet()) {
             Class<?> type = entry.getKey();
             String base = classMappingPath(type);
@@ -338,9 +369,19 @@ class LeshouyingAdminWiringTest {
                 assertNotNull(pa, where + " 缺 @PreAuthorize —— 该端点会**同时**失去认证与租户条件"
                         + "(TenantInnerInterceptor 只在已认证请求上追加 tenant_id),"
                         + "而本控制器的类 javadoc 正是以「所有端点都有认证」为前提的");
-                Set<String> perms = quotedLiterals(pa.value());
-                assertFalse(perms.isEmpty(), where + " 的 @PreAuthorize 里取不到权限串: " + pa.value());
-                declaredPermissions.addAll(perms);
+                Set<String> quoted = quotedLiterals(pa.value());
+                assertFalse(quoted.isEmpty(), where + " 的 @PreAuthorize 里一个引号字面量都没有 ⇒ "
+                        + "要么注解被写成了不带参数的形状,要么下面的取数式失效了: " + pa.value());
+                // ★ 引号字面量要**按它出现在哪个调用里**分类(2026-09-30):
+                //   `hasRole('X')` 的 X 是**角色名**,其余才是权限串。
+                //   不分类的话,`hasRole('SUPERUSER')` 会被当成权限串去和菜单契约比对
+                //   ⇒ 报「授不出来的野串 SUPERUSER」——一条**假红**,而修它的人会去动菜单 SQL。
+                //   分类依据是 SpEL 里**实际调用的方法名**,不是字符串的形状,
+                //   所以写成大写/无冒号的**权限串**(真错法)照样落在 permissionLiterals 里、照样红。
+                for (String r : roleLiterals(pa.value())) {
+                    roles.add(normalizeRole(r));   // 收集时就规范化,腿③之三与它逐字比
+                }
+                declaredPermissions.addAll(permissionLiterals(pa.value()));
 
                 // ── 腿④:SpEL 引用的 bean 名 == @Component("perm") 的注册名 ──────────────
                 assertEquals(Set.of(registeredBean), beanNamesIn(pa.value()),
@@ -360,6 +401,24 @@ class LeshouyingAdminWiringTest {
                 + "都不存在的权限串 ⇒ 该权限永远授不出来(非超管一律 403): " + strays
                 + ";契约清单=" + menuPermissions);
 
+        // ── 腿③之三:身份门禁里**只允许 SUPERUSER**(2026-09-30)───────────────────────
+        // 语义上没有「给某个角色开个后门」这回事,两个理由各自独立:
+        //   ① 角色码是**数据**(sys_role 里可被超管新增/改名),而 is_superuser 是**列**;
+        //      权限串那一侧至少还有一份菜单 SQL 当契约,角色名没有任何可对齐的事实来源;
+        //   ② 更要紧的是:除 SUPERUSER 外**没有任何角色会 bypass 租户拦截器**
+        //      (TenantInnerInterceptor.ignoreTable 只认 isSuperuser)。被别的角色码放行的调用方
+        //      以为自己能跨租户写,而 SQL 上又被追加了 tenant_id —— 一边拒绝一边以为放行,
+        //      三种组合(写入被改写 / 0 行 / 报错)没有一种能在界面上说清。
+        //      ★ 确实需要「让某个角色也能建号」时,做法是**权限串** + 在服务层显式校验归属,
+        //        不是把这里的 SUPERUSER 换成角色码。
+        // ★ 比的是 `ROLE_` 前缀规范化之后的名字,因为 PermissionCheck.hasRole 自己就接受两种写法
+        //   (`SUPERUSER` 与 `ROLE_SUPERUSER` 等价)——只认一种的话,一次等价改写会变成假红。
+        Set<String> unknownRoles = new LinkedHashSet<>(roles);
+        unknownRoles.remove(ROLE_SUPERUSER_AUTHORITY);
+        assertTrue(unknownRoles.isEmpty(), "管理端 @PreAuthorize 里出现了 SUPERUSER 之外的角色门禁: "
+                + unknownRoles + "(实测全部角色门禁=" + roles + ")。见本段代码上方的两条理由;"
+                + "要让某个角色也能建号,请改用权限串并在服务层显式校验归属");
+
         // ── 腿⑤:登记**完备**性(把上面那条腿缺的那个方向补上)──────────────────────
         // 上面只断言 declared ⊆ contract。这个方向对「**新控制器忘了登记**」是**瞎的**:
         // 把 LeshouyingCashierController 从 ADMIN_CONTROLLERS 里删掉,declared 只是少三条串,
@@ -384,6 +443,53 @@ class LeshouyingAdminWiringTest {
                 + "它的菜单脚本到 MENU_SQLS): " + unbacked);
     }
 
+    /**
+     * 商户号的**写入口只认超管身份**,不认权限串(2026-09-30,用户裁定
+     * 「新增商户号是超管操作,需要选择租户,租户不允许自己录入商户号」)。
+     *
+     * <p>【为什么必须是身份而不是权限串】权限串是「授给哪个角色、哪个角色就能调」,
+     * 而本端点的约束是**身份**。这不是审美:只有超管 bypass 了
+     * {@code TenantInnerInterceptor},别的角色没有 —— 一个被权限串放行的非超管调用方
+     * 可以**指定任意 tenantId**,而 insert 时既不会被改写成自己的租户
+     * (显式给值 ⇒ 列清单无条件含 tenant_id ⇒ 拦截器的追加分支被短路),也不会报错。
+     * ⇒ 权限串那条路等于把「替任意租户建号」开给了任何拿到该串的角色,而这一行决定的是
+     * 钱进哪个乐收赢商户号。
+     *
+     * <p>★ 与 HAR 的 {@code HarMerchantController#save/delete} 同一条裁定、同一个理由
+     * (仓库里这个形状的第一个实例)。差别是**那边没有同类守卫**:本类只守
+     * {@link #ADMIN_CONTROLLERS} 里登记过的控制器,而 HAR 那个不在其中。
+     *
+     * <p>会变红的改动:把任一个改回 {@code @perm.hasAny('module_payment:leshouying:merchant:list')}
+     * —— 那正是这两个端点改动前的样子,也是「照抄同文件其它方法的形状」最容易抄出来的样子。
+     */
+    @Test
+    void merchantWriteEndpoints_areGatedBySuperuserIdentity_notByAPermissionString() {
+        for (String name : List.of("save", "delete")) {
+            Method m = Arrays.stream(LeshouyingMerchantController.class.getDeclaredMethods())
+                    .filter(x -> x.getName().equals(name))
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError(
+                            "LeshouyingMerchantController 里找不到 " + name + " —— 端点被删或改名后,"
+                                    + "本用例会**静默地什么都不守**,故这里响亮失败"));
+            PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
+            assertNotNull(pa, name + " 缺 @PreAuthorize(同 everyAdminMappedEndpoint_… 的腿②)");
+
+            Set<String> normalized = new LinkedHashSet<>();
+            for (String r : roleLiterals(pa.value())) {
+                normalized.add(normalizeRole(r));
+            }
+            assertEquals(Set.of(ROLE_SUPERUSER_AUTHORITY), normalized,
+                    name + " 的角色门禁不是超管(或压根没有身份门禁)。实测: " + pa.value());
+
+            Set<String> perms = permissionLiterals(pa.value());
+            assertTrue(perms.isEmpty(), name + " 上还挂着权限串 " + perms
+                    + " —— 身份门禁与权限串是两种判据;同时挂上时超管两种都过,"
+                    + "于是没人会发现「授了这个权限串的角色其实仍然进不来」,"
+                    + "而界面上一旦按权限串显示按钮,非超管就会看到一个点了必 403 的入口。"
+                    + "实测: " + pa.value());
+        }
+    }
+
     /**
      * 取生产 `TenantApiKeyAuthFilter.OPENAPI_PREFIXES` 的**唯一**反射读取点。
      * 两处用它,保证读到的是同一个数组、同一份语义。
@@ -471,6 +577,42 @@ class LeshouyingAdminWiringTest {
         return out;
     }
 
+    /**
+     * SpEL 里 {@code hasRole('X')} 的 X,**原样**(不规范化)—— 门禁里被引用的角色名。
+     *
+     * <p>与 {@link #quotedLiterals} 的分工见 {@link #permissionLiterals}。
+     */
+    private static Set<String> roleLiterals(String spel) {
+        Set<String> out = new LinkedHashSet<>();
+        Matcher m = ROLE_CALL.matcher(spel);
+        while (m.find()) {
+            out.add(m.group(1));
+        }
+        return out;
+    }
+
+    /**
+     * 规范化成 authorities 里的名字:缺 {@code ROLE_} 前缀就补上。
+     * 规则**照抄** {@code PermissionCheck.hasRole} 的第一行(那边是 {@code startsWith} 判断,
+     * 这里是等价的正向表达)——两边漂移的后果是把一次等价改写判成假红。
+     */
+    private static String normalizeRole(String role) {
+        return role.startsWith("ROLE_") ? role : "ROLE_" + role;
+    }
+
+    /**
+     * SpEL 里的**权限串**:所有引号字面量**减去** {@code hasRole(...)} 的参数。
+     *
+     * <p>★ 为什么要减去:{@link #MENU_SQLS} 是权限串的契约来源,而角色名不在任何一份菜单 SQL 里
+     * —— 不减的话 {@code hasRole('SUPERUSER')} 会被判成「授不出来的野串」,是一条**假红**。
+     * 分类依据必须是调用形状(见 {@link #ROLE_CALL}),不能是字面量的形状。
+     */
+    private static Set<String> permissionLiterals(String spel) {
+        Set<String> out = quotedLiterals(spel);
+        out.removeAll(roleLiterals(spel));
+        return out;
+    }
+
     /** SpEL 里所有 bean 引用({@code @beanName.})的名字。 */
     private static Set<String> beanNamesIn(String spel) {
         Set<String> out = new LinkedHashSet<>();

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

@@ -695,6 +695,7 @@ class LsyMerchantServiceTest {
         LsyMerchantEntity body = new LsyMerchantEntity();
         body.setEnterpriseId("ENT1");
         body.setMchNo("Y52211090004");
+        body.setTenantId(77L);   // 归属必填(见下面「归属」一节)—— 本用例管的是默认值,随便给一个
         // 模拟 MP 的 IdType.ASSIGN_ID:insert 时把雪花 id 写回实体(mock 不做这件事,不模拟回读的就是 null)
         when(merchantMapper.insert(any())).thenAnswer(inv -> {
             ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
@@ -718,6 +719,7 @@ class LsyMerchantServiceTest {
         LsyMerchantEntity body = new LsyMerchantEntity();
         body.setEnterpriseId("   ");       // 只有空白 —— 必须与 null 同等对待
         body.setMchNo("Y52211090004");
+        body.setTenantId(77L);             // 同上:归属必填
         when(merchantMapper.insert(any())).thenAnswer(inv -> {
             ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
             return 1;
@@ -733,6 +735,94 @@ class LsyMerchantServiceTest {
         verify(merchantMapper, never()).update(any(), any());
     }
 
+    // ─────────────────────────── save():归属(2026-09-30) ───────────────────────────
+    //
+    // 【为什么归属成了必填】录入商户号从「租户自助」改成了**超管代录**(用户裁定:
+    // 「新增商户号是超管操作,需要选择租户,租户不允许自己录入商户号」)。
+    // 门禁在前端入口与控制器注解上(`@perm.hasRole('SUPERUSER')`)——服务层看不见身份,
+    // 故这里不判「你是不是超管」,只判「归属说清楚了没有」。
+    //
+    // 【为什么必须显式给值,而不是让它自己填】超管 bypass 了
+    // TenantInnerInterceptor.ignoreTable(那边 isSuperuser ⇒ true),insert 时**没有任何机制**
+    // 会替我们改写 tenant_id;而 AutoFillMetaObjectHandler 只在字段为 null 时才填。
+    // ⇒ 不给值的后果**不是报错**,而是**静默**落到超管自己那个租户上:界面上与成功无法区分,
+    // 而这一行决定的是**钱进哪个乐收赢商户号**。这个静默分支由
+    // save_insertBranch_explicitTenant_isWrittenVerbatim 的**正对照**钉住
+    // (只断言「缺租户就 400」的话,把 tenant_id 在 insert 前覆盖成任意值也能全绿)。
+
+    @Test
+    void save_insertBranch_missingTenant_throws400() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setMchNo("Y52211090004");
+
+        BusinessException ex = assertThrows(BusinessException.class, () -> service.save(body));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("租户"),
+                "提示要让操作员知道缺的是哪一格。实测: " + ex.getMessage());
+        verify(merchantMapper, never()).insert(any());
+    }
+
+    /**
+     * 正对照:归属给值时必须**原样**落库。
+     *
+     * <p>上面那条只断言「缺租户 ⇒ 400」,它拦不住「拿到了租户却又改成别的值」——
+     * 本条钉的是那个方向(例如有人加了句 {@code entity.setTenantId(LoginUser.getTenantId())}
+     * 兜底,缺租户那条用例照样绿,而超管代录的每一行都会落到超管自己的租户上)。
+     */
+    @Test
+    void save_insertBranch_explicitTenant_isWrittenVerbatim() {
+        LsyMerchantEntity body = new LsyMerchantEntity();
+        body.setMchNo("Y52211090004");
+        body.setTenantId(77L);
+        when(merchantMapper.insert(any())).thenAnswer(inv -> {
+            ((LsyMerchantEntity) inv.getArgument(0)).setId(9L);
+            return 1;
+        });
+        when(merchantMapper.selectById(9L)).thenReturn(row(9L));
+
+        service.save(body);
+
+        ArgumentCaptor<LsyMerchantEntity> inserted = ArgumentCaptor.forClass(LsyMerchantEntity.class);
+        verify(merchantMapper).insert(inserted.capture());
+        assertEquals(77L, inserted.getValue().getTenantId(),
+                "归属必须原样落库。实测: " + inserted.getValue().getTenantId());
+    }
+
+    /**
+     * 编辑分支**不接受**改归属:请求体里带的 tenant_id 进不了 SET。
+     *
+     * <p>★ 这是**结构保证**(SET 列表是 5 个业务列的字面枚举,tenant_id 不在其中),
+     * 不是一句「我们会小心」——同一条性质由
+     * {@link #save_updateBranch_writesExactlyTheSixOwnedColumns} 的
+     * {@code assertFalse(cols.contains("tenant_id"))} 从**列集合**那一侧再钉一次。
+     * 两处都留着:本条读的是「带着一个不同的 tenant_id 进来时 SET 里仍没有它」,
+     * 那条读的是「不带时也没有」——只留一条的话,另一种输入形态就没被走过。
+     *
+     * <p>★ 为什么**不**读一次旧行、比对后报 400:那会往编辑分支插一次
+     * {@code selectById},而 {@link #save_updateBranch_zeroAffectedRows_throws404} 明确钉着
+     * 「0 行路径不得回读」({@code verify(merchantMapper, never()).selectById(any())})。
+     * 为一条用户没要求的校验去拆掉一条既有断言不划算;改归属这个动作在**服务端结构上**
+     * 已经不成立,界面那一侧还有一道(编辑态的租户下拉置灰)。
+     */
+    @Test
+    void save_updateBranch_tenantFromBody_neverReachesTheSetClause() {
+        when(merchantMapper.update(any(), any())).thenReturn(1);
+        when(merchantMapper.selectById(any())).thenReturn(row(EDIT_ID));
+
+        LsyMerchantEntity body = editBody();
+        body.setTenantId(88L);            // 与库里的行(row() 不带 tenantId)不同的一个归属
+        service.save(body);
+
+        LambdaUpdateWrapper<?> w = capturedUpdate();
+        assertFalse(setColumns(w).contains("tenant_id"),
+                "tenant_id 绝不进 SET —— 改了归属等于让同一笔业务横跨两个租户。实测: " + w.getSqlSet());
+        // WHERE 也不得带上它:带上的话「归属不同」会变成 404「不存在」,把一次越权改归属
+        // 说成「这行没了」,排查时会往删行那个方向找
+        assertFalse(w.getSqlSegment().contains("tenant_id"),
+                "WHERE 也只有 id。实测: " + w.getSqlSegment());
+    }
+
     // ──────────────── updateReviewStatusByMchNo:乐收赢审核通知的写入口 ────────────────
     //
     // 【为什么这个写入口不复用 requireByMchNo,也不复用 save】