4 Commits 2c87ea2868 ... 92b8543625

Author SHA1 Message Date
  alphaH 92b8543625 docs(汇安融门店): 建店页两处注释同步超管腿口径 6 days ago
  alphaH 00267c25db feat(汇安融门店): 建店改超管口径,商户按编号全局解析、归属取自商户行 6 days ago
  alphaH 38c82c53a6 feat(汇安融订单): 列表与详情隐藏门店栏目 6 days ago
  alphaH 0b0e6494bc docs(开放文档): 10 章隐藏 shop_code / third_shop_code 6 days ago

+ 0 - 6
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -13,8 +13,6 @@
 
 > **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。三个接口都填同一个 `merchant_code` 即可。
 
-**门店与下单的关系**:10.1 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
-
 ---
 
 ### 10.1 下单收款
@@ -31,8 +29,6 @@ POST /payment/openapi/alipay/order/create
 | `price` | number | 是  | 金额,**单位元**,必须 > 0,见下 |
 | `title` | string | 是  | 商品名称,≤64 字符 |
 | `out_biz_no` | string | 否  | 你自己的订单号,≤64 字符,原样存下供你查询 |
-| `shop_code` | string | 否  | 平台门店编号 |
-| `third_shop_code` | string | 否  | 门店编码 |
 | `ip` | string | 是  | 顾客用户端ip |
 | `pay_timeout` | int | 否  | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) |
 
@@ -48,7 +44,6 @@ POST /payment/openapi/alipay/order/create
 | `trade_status` | 订单状态,见 10.5 |
 | `price` | 订单金额(元) |
 | `title` | 原样返回 |
-| `shop_code` / `third_shop_code` | 你传的门店编码原样返回 |
 | `trade_time` | 支付成功时间 |
 | `rcv_amount` | 实收金额(元),支付成功后有值 |
 | `fee_amount` / `channel_amount` | 手续费 / 渠道金额 |
@@ -176,7 +171,6 @@ Content-Type: application/x-www-form-urlencoded
   "order_no": "2026092499001",
   "platform_trade_no": "2026092499002",
   "merchant_code": "M0001",
-  "third_shop_code": "S001",
   "price": 1.50,
   "title": "测试商品",
   "status": "1",

+ 0 - 9
frontend/src/views/module_payment/har/order/index.vue

@@ -146,9 +146,6 @@
         <el-table-column prop="merchant_code" label="商户编号" min-width="140">
           <template #default="{ row }">{{ row.merchant_code || "-" }}</template>
         </el-table-column>
-        <el-table-column prop="third_shop_code" label="门店编码" min-width="130">
-          <template #default="{ row }">{{ row.third_shop_code || "-" }}</template>
-        </el-table-column>
         <el-table-column prop="platform_trade_no" label="支付宝交易号" min-width="200" show-overflow-tooltip>
           <template #default="{ row }">{{ row.platform_trade_no || "-" }}</template>
         </el-table-column>
@@ -381,12 +378,6 @@
         <el-descriptions-item label="商户编号">
           {{ detail.merchant_code || "-" }}
         </el-descriptions-item>
-        <el-descriptions-item label="门店编码">
-          {{ detail.third_shop_code || "-" }}
-        </el-descriptions-item>
-        <el-descriptions-item label="平台门店编号">
-          {{ detail.shop_code || "-" }}
-        </el-descriptions-item>
         <el-descriptions-item label="上游订单号" :span="2">
           {{ detail.upstream_order_no || "-" }}
         </el-descriptions-item>

+ 9 - 5
frontend/src/views/module_payment/har/shop/index.vue

@@ -158,8 +158,9 @@
       />
       <el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
         <el-form-item label="商户" prop="merchant_code">
-          <!-- 可清空 + 可输入:本租户配了多个商户时这里就是「用哪一条」的开关;
-               列表拉不到时(未授权/接口异常)仍可手工敲编号,取值由后端按本租户校验 -->
+          <!-- 可清空 + 可输入:配了多个商户时这里就是「用哪一条」的开关;
+               列表拉不到时(未授权/接口异常)仍可手工敲编号。取值由后端按调用方校验:
+               租户调用限本租户(跨租户 404);超管腿(2026-10-09 建店口径)全局解析 -->
           <el-select
             v-model="form.merchant_code"
             placeholder="留空即按企业与租户自动匹配"
@@ -311,7 +312,7 @@ import { generateShopCode } from "@/utils/shop-code";
 
 const enterpriseStore = useEnterpriseStore();
 
-/** 本租户可见的商户配置 —— 建店时用哪一条由这里选 */
+/** 商户配置下拉 —— 租户调用看到本租户的,超管(2026-10-09 建店口径)看到全部租户的 */
 const merchantOptions = ref<HarMerchantVO[]>([]);
 
 /**
@@ -425,6 +426,8 @@ const rules: FormRules = {
   // 企业**非必填**:留空即按租户建店(enterprise_id 落 NULL 的租户级映射),
   // 与 HarShopCreateDTO 上「选填」的注释、后端 HarShopService#create 的分流一致。
   // 商户同理非必填:本租户只有一条配置时留空即为那条;有多条时后端会 400 要求指定。
+  // 超管建店(2026-10-09)则**必须**指定商户 —— 门店归属取自所选商户的租户,
+  // 超管没有可回退的租户级配置,两者都不填后端会 400。
   third_shop_id: [{ required: true, message: "请填写门店编码", trigger: "blur" }],
   shop_name: [{ required: true, message: "请填写门店名称", trigger: "blur" }],
   // 地区两个都必填(规范 261008 5.5):这既是发上游的必填项,也是按顾客 IP 挑店的键。
@@ -441,8 +444,9 @@ async function loadMerchantOptions() {
     const res = await HarAPI.merchantList({ page_size: 100 });
     merchantOptions.value = res.data.data?.items || res.data.data?.list || [];
   } catch {
-    // 拉不到(未授权/接口异常)不阻塞建店:下拉留空,仍可手工输入编号,
-    // 归属由后端按本租户校验(跨租户的会被 404 挡掉)
+    // 拉不到(未授权/接口异常)不阻塞建店:下拉留空,仍可手工输入编号。
+    // 归属由后端按调用方校验:租户调用限本租户(跨租户 404);超管可取任意
+    // 租户的编号(2026-10-09),门店归属取自该商户行
     merchantOptions.value = [];
   }
 }

+ 7 - 0
java/src/main/java/com/payment/platform/module/payment/har/controller/HarShopController.java

@@ -46,6 +46,13 @@ public class HarShopController {
         return Result.ok(HarShopVO.of(service.getById(id)));
     }
 
+    /**
+     * 创建门店。门店归属(tenant_id)由服务层取自**解析出的商户行**(2026-10-09 起,
+     * 用户口径「选的是哪个商户就用商户表的租户id」):管理端由超管操作 —— 可取任意
+     * 租户的商户编号(按编号全局解析;企业号与商户编号都没指定时 400 拒绝,
+     * 不回退超管自己的租户);租户调用仍限在自己租户内。
+     * 分流判据 = {@code LoginUser#getIsSuperuser},见 {@link HarShopService#create}。
+     */
     @PostMapping
     @Operation(summary = "创建门店(调用上游)")
     @PreAuthorize("@perm.hasAny('module_payment:har:shop:create')")

+ 2 - 1
java/src/main/java/com/payment/platform/module/payment/har/controller/open/HarOpenController.java

@@ -98,7 +98,8 @@ public class HarOpenController {
      * 校验已通过 API Key 认证,且本次调用有可用的商户配置。
      *
      * 三条路径**并存**,次序就是优先级(与 HarOrderService / HarShopService 内的解析
-     * 完全一致 —— 这里是闸,那里是防御性的第二道,几处必须同时改):
+     * 完全一致 —— 这里是闸,那里是防御性的第二道,几处必须同时改;★ HarShopService
+     * 2026-10-09 起在 ① 上另有**超管腿**(管理端建店),开放 API 没有超管调用方,走不到):
      * ① 传了 merchant_code ⇒ 按它取,必须属于本租户(2026-09-24 起一个租户可配置多个商户);
      * ② 传了 enterprise_id ⇒ 企业级(同一租户下可能有多个企业,只靠租户过滤会让
      *    A 企业查到 B 企业的订单);

+ 6 - 1
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopCreateDTO.java

@@ -33,7 +33,12 @@ public class HarShopCreateDTO {
      * (创建门店的报文里就带 merchant_code),所以由调用方指定才是准的。
      * 不指定时:该租户只有一条配置 ⇒ 用它;有多条 ⇒ **拒绝**并提示指定,
      * 绝不替调用方挑一条(挑错了门店就挂到别的商户名下,且没有任何报错)。
-     * 取值必须是**本租户**的商户编号,跨租户的会被 404 挡掉。
+     *
+     * <p>取值按**调用方**解析(2026-10-09 门店改由超管创建起):租户调用与开放 API
+     * 必须是**本租户**的编号,跨租户的会被 404 挡掉;管理端由超管操作 —— 可取**任意
+     * 租户**的编号(按编号全局解析,merchant_code 全局唯一),且门店归属(tenant_id)
+     * 取自该商户行。超管不指定时**一律拒绝**(400):超管没有可回退的租户级配置,
+     * 回退等于静默挂错租户。
      */
     private String merchantCode;
 

+ 30 - 1
java/src/main/java/com/payment/platform/module/payment/har/service/HarMerchantService.java

@@ -173,7 +173,11 @@ public class HarMerchantService {
         return rows.get(0);
     }
 
-    /** 指定了商户编号时用它,查不到即 404(越权与不存在合并成一句,不区分) */
+    /**
+     * 指定了商户编号时的**租户腿**(租户调用与开放 API),查不到即 404(越权与不存在合并成一句,不区分)。
+     *
+     * <p>管理端超管腿走 {@link #requireByCode} —— 分流判据 = {@code LoginUser#getIsSuperuser}。
+     */
     public HarMerchantEntity requireByTenantAndCode(Long tenantId, String merchantCode) {
         HarMerchantEntity entity = getByTenantAndCode(tenantId, merchantCode);
         if (entity == null) {
@@ -182,6 +186,31 @@ public class HarMerchantService {
         return entity;
     }
 
+    /**
+     * 按商户编号**全局**取(**超管腿** —— 2026-10-09 门店改由超管创建)。
+     *
+     * <p>不带租户谓词:超管没有归属租户,而 merchant_code 全局唯一(uk_har_merchant_code),
+     * 编号天生只指向一行 —— 这不是「放宽校验」,是这条腿本来就没有租户可限定。
+     * 分流判据必须与 {@code TenantInnerInterceptor#ignoreTable} 是**同一个开关**
+     * ({@code LoginUser#getIsSuperuser},同 {@link #requireById} 那条腿的论证);
+     * 租户调用与开放 API **不许**换用本方法,它们的边界由
+     * {@link #requireByTenantAndCode} 的租户谓词守住。
+     *
+     * @throws BusinessException 400 编号为空;404 查不到
+     */
+    public HarMerchantEntity requireByCode(String merchantCode) {
+        if (StrUtil.isBlank(merchantCode)) {
+            throw new BusinessException(400, "商户编号不能为空");
+        }
+        HarMerchantEntity entity = merchantMapper.selectOne(new LambdaQueryWrapper<HarMerchantEntity>()
+                .eq(HarMerchantEntity::getMerchantCode, merchantCode)
+                .last("LIMIT 1"));
+        if (entity == null) {
+            throw new BusinessException(404, "商户编号不存在: " + merchantCode);
+        }
+        return entity;
+    }
+
     /**
      * 新增或修改商户配置。**只由超管调用** —— 门禁在 HarMerchantController#save 的
      * {@code @PreAuthorize("@perm.hasRole('SUPERUSER')")},不在本方法里。

+ 35 - 5
java/src/main/java/com/payment/platform/module/payment/har/service/HarShopService.java

@@ -284,8 +284,9 @@ public class HarShopService {
         // 挑店时却比不上的静默失配。
         HarRegionCatalog.City city = requireKnownCity(dto.getProvinceCode(), dto.getCityCode());
         // 企业为空 ⇒ 按**租户**寻址(去企业化,2026-09-24,见 db/har-tenant-level.sql)。
-        // 分流规则与 HarOpenController#requireMerchant、HarOrderService#create
-        // 是同一套 —— 三处必须同时改。
+        // 企业与租户的分流规则与 HarOpenController#requireMerchant、HarOrderService#create
+        // 是同一套 —— 三处必须同时改。★ 例外只有一条:下方 ① 在本文件另有**超管腿**
+        // (2026-10-09 门店改由超管创建),另外两处没有 —— 改分流前先看它。
         String ownerEnterpriseId = StrUtil.isNotBlank(enterpriseId)
                 ? enterpriseId : dto.getEnterpriseId();
         if (getByThirdShopId(ownerEnterpriseId, dto.getThirdShopId()) != null) {
@@ -293,14 +294,25 @@ public class HarShopService {
         }
         // 三条分支,次序就是优先级:
         // ① 显式 merchant_code —— 一个租户可以有多条配置(2026-09-24),
-        //    门店铺在上游是挂在商户下的,调用方指定才是准的;
+        //    门店铺在上游是挂在商户下的,调用方指定才是准的。
+        //    2026-10-09 起管理端建店由**超管**操作 ⇒ 超管腿按编号**全局**解析
+        //    (超管没有归属租户,带租户谓词只会把它挡死);租户调用与开放 API
+        //    仍是带租户谓词的那条,语义不变。门店归属两条腿都取自商户行(见下方
+        //    entity.setTenantId);
         // ② 企业级映射;③ 租户级(此时只有一条才成立,多条由 requireByTenant 报 400 拦下)。
         HarMerchantEntity merchant;
         if (StrUtil.isNotBlank(dto.getMerchantCode())) {
-            merchant = merchantService.requireByTenantAndCode(
-                    getCurrentTenantId(), dto.getMerchantCode());
+            merchant = isSuperuser()
+                    ? merchantService.requireByCode(dto.getMerchantCode())
+                    : merchantService.requireByTenantAndCode(
+                            getCurrentTenantId(), dto.getMerchantCode());
         } else if (StrUtil.isNotBlank(ownerEnterpriseId)) {
             merchant = merchantService.requireByEnterpriseId(ownerEnterpriseId);
+        } else if (isSuperuser()) {
+            // 超管没有「本租户配置」可回退(tenantId 可以是 null)⇒ 不指定就拒绝。
+            // 回退到超管自己的租户会挑出一条商户并**静默**把门店挂错租户 —— 宁可报错。
+            throw new BusinessException(400,
+                    "请指定商户编号 merchant_code:门店归属取自所选商户的租户");
         } else {
             merchant = merchantService.requireByTenant(getCurrentTenantId());
         }
@@ -314,6 +326,12 @@ public class HarShopService {
 
         // 1. 先落 PENDING —— 无论后续发生什么都留下记录
         HarShopEntity entity = new HarShopEntity();
+        // 归属取自**商户行**(用户口径:「选的是哪个商户就用商户表的租户id」)。
+        // 租户调用/开放 API 下它就是当前租户(解析带着租户谓词,与 AutoFill 会填的值一致);
+        // 超管腿下这是**唯一**正确的来源 —— 超管 bypass 了租户拦截器,AutoFill 只会拿
+        // 超管自己的 tenantId(可以是 null)。AutoFill 只在值为 null 时兜底、不会覆盖这里
+        // (见 AutoFillMetaObjectHandler#insertFill),所以显式 set 是有效且必要的。
+        entity.setTenantId(merchant.getTenantId());
         // 空 ⇒ NULL,不是空串:租户级门店找的正是 enterprise_id IS NULL
         // (见 getByThirdShopId 与 uk_har_shop_tenant_third_shop)。
         // 口径同 LsyStrings.blankToNull(isBlank ⇒ null,非空白原样,不 trim)。
@@ -424,4 +442,16 @@ public class HarShopService {
         }
         return null;
     }
+
+    /**
+     * 当前身份是不是超管 —— 分岔判据必须与 {@code TenantInnerInterceptor#ignoreTable}
+     * 用的是**同一个开关**({@code LoginUser#getIsSuperuser},同
+     * {@code HarMerchantController#requireMerchantForCurrentUser} 的论证)。
+     * 换成别的判据(例如「tenantId 是不是 null」)会出现「一边以为放行、另一边仍在过滤」。
+     */
+    private boolean isSuperuser() {
+        var auth = SecurityContextHolder.getContext().getAuthentication();
+        return auth != null && auth.getPrincipal() instanceof LoginUser user
+                && Boolean.TRUE.equals(user.getIsSuperuser());
+    }
 }

+ 45 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarMerchantServiceTest.java

@@ -337,6 +337,51 @@ class HarMerchantServiceTest {
         verify(merchantMapper, never()).selectOne(any());
     }
 
+    // ==================== 按商户编号寻址(超管腿,2026-10-09 超管建店) ====================
+
+    /**
+     * 超管腿按编号**全局**取 —— 谓词里**不能**有 tenant_id。
+     *
+     * <p>与 {@link HarMerchantService#getByTenantAndCode} 是并存的另一条腿(那是租户调用与
+     * 开放 API 的):merchant_code 全局唯一(uk_har_merchant_code),编号天生只指一行;
+     * 而超管没有归属租户 —— 带上租户谓词等于给「替任意租户建店」加了一道查不到的门。
+     */
+    @Test
+    void requireByCode_isGlobal_notTenantScoped() {
+        HarMerchantEntity row = merchant(11L, "M9");
+        row.setTenantId(9L);          // 别的租户 —— 超管建店正是要读它的行
+        when(merchantMapper.selectOne(any())).thenReturn(row);
+
+        assertSame(row, service.requireByCode("M9"));
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        ArgumentCaptor<LambdaQueryWrapper<HarMerchantEntity>> cap =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(merchantMapper).selectOne(cap.capture());
+        String where = cap.getValue().getSqlSegment();
+        assertTrue(where.contains("merchant_code"), "实测 WHERE: " + where);
+        assertFalse(where.contains("tenant_id"), "超管腿跨租户,不能带租户谓词,实测 WHERE: " + where);
+    }
+
+    @Test
+    void requireByCode_unknown_throws404() {
+        when(merchantMapper.selectOne(any())).thenReturn(null);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.requireByCode("M999"));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+    }
+
+    /** 空编号**先拒后查**:真查下去只会得到 `merchant_code = ''` 的恒空条件,同样是查不到,但报错文案对调用方毫无指引 */
+    @Test
+    void requireByCode_blank_throws400_withoutQuerying() {
+        BusinessException e = assertThrows(BusinessException.class, () -> service.requireByCode("  "));
+
+        assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantMapper, never()).selectOne(any());
+    }
+
     // ==================== 按主键寻址(管理端「取收款链接」两道闸的两条腿) ====================
 
     /**

+ 81 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarShopServiceTest.java

@@ -396,6 +396,87 @@ class HarShopServiceTest {
         assertEquals("M700", req.getValue().getMerchantCode(), "发给上游的必须是调用方指定的那个");
     }
 
+    // ==================== 超管建店(2026-10-09:门店改由超管创建) ====================
+
+    /**
+     * ★ 超管 + 显式 merchant_code ⇒ 按编号**全局**解析(无租户谓词),门店归属落
+     * **商户行的租户**(用户口径:「选的是哪个商户就用商户表的租户id」)。
+     *
+     * <p>两个断言各钉一件事:① {@code requireByCode} 被调用、租户腿没被调用;
+     * ② insert 那一刻的 {@code tenantId} == 商户行的租户。后者是这条链上最容易静默错的一环:
+     * AutoFillMetaObjectHandler **只在值为 null 时**才用 LoginUser 填 —— 少了显式 set,
+     * 超管(tenantId 可以是 null)建出来的门店要么无归属、要么落成超管自己的租户,
+     * 全程没有报错。dto 里保留 E1 是钉优先级:merchant_code 仍压过企业号。
+     */
+    @Test
+    void create_superuser_resolvesMerchantGlobally_andStampsMerchantTenant() {
+        HarMerchantEntity foreign = new HarMerchantEntity();
+        foreign.setTenantId(9L);
+        foreign.setEnterpriseId(null);
+        foreign.setMerchantCode("M9");
+        when(merchantService.requireByCode("M9")).thenReturn(foreign);
+        when(shopMapper.selectOne(any())).thenReturn(null);
+        snapshotStatusAtInsert();
+        when(harClient.createShop(any())).thenReturn(response("10000", "{\"shop_id\":\"S1\"}"));
+        HarShopCreateDTO dto = dto();          // 带 enterpriseId = "E1"
+        dto.setMerchantCode("M9");
+
+        HarShopVO vo = HarTestAuth.asSuperuser(() -> service.create(dto, null));
+
+        verify(merchantService).requireByCode("M9");
+        verify(merchantService, never()).requireByTenantAndCode(any(), any());
+        verify(merchantService, never()).requireByEnterpriseId(any());
+        ArgumentCaptor<HarShopEntity> captor = ArgumentCaptor.forClass(HarShopEntity.class);
+        verify(shopMapper).insert(captor.capture());
+        assertEquals(9L, captor.getValue().getTenantId(), "门店归属必须取自商户行,实测: "
+                + captor.getValue().getTenantId());
+        assertEquals("M9", vo.getMerchantCode());
+    }
+
+    /**
+     * ★ 超管**不指定商户**(也没有企业号)⇒ 400 明确拒绝,**不许**拿超管自己的租户回退。
+     *
+     * <p>钉的是「静默错归属」:超管带着 tenant_id 时(sys_user.tenant_id 可空也能有值),
+     * 回退到 requireByTenant(他自己的租户) 会挑出一条商户并把门店挂到那个租户下 ——
+     * 全程没有报错,与「选的是哪个商户就用商户表的租户id」相悖。
+     */
+    @Test
+    void create_superuserWithoutMerchantCode_rejectsInsteadOfGuessing() {
+        HarShopCreateDTO dto = dto();
+        dto.setEnterpriseId(null);             // 企业、商户都不指定
+
+        BusinessException e = HarTestAuth.asSuperuser(7L, () ->
+                assertThrows(BusinessException.class, () -> service.create(dto, null)));
+
+        assertEquals(400, e.getCode(), "缺商户是入参问题,实测消息: " + e.getMessage());
+        assertTrue(e.getMessage().contains("merchant_code"),
+                "报错必须告诉调用方补哪个字段,实测消息: " + e.getMessage());
+        verify(merchantService, never()).requireByTenant(any());
+        verify(merchantService, never()).requireByCode(any());
+        verify(shopMapper, never()).insert(any());
+        verify(harClient, never()).createShop(any());
+    }
+
+    /**
+     * ★ 反向钉:**非超管**不得走全局解析 —— 租户 A 拿租户 B 的商户号建店仍是 404
+     * (这是 HarMerchantService#requireByTenantAndCode 租户谓词所守的安全边界;
+     * 平铺去掉租户校验会让持有建店权限的租户用户跨租户写,且上游真会建出门店)。
+     */
+    @Test
+    void create_tenantUser_neverResolvesMerchantGlobally() {
+        when(merchantService.requireByTenantAndCode(7L, "M9"))
+                .thenThrow(new BusinessException(404, "商户编号不存在或不属于当前租户: M9"));
+        HarShopCreateDTO dto = dto();
+        dto.setMerchantCode("M9");
+
+        BusinessException e = HarTestAuth.asTenant(7L, () ->
+                assertThrows(BusinessException.class, () -> service.create(dto, null)));
+
+        assertEquals(404, e.getCode(), "实测消息: " + e.getMessage());
+        verify(merchantService, never()).requireByCode(any());
+        verify(shopMapper, never()).insert(any());
+    }
+
     /**
      * 租户级查重必须落成 {@code enterprise_id IS NULL} 而**不是** {@code = NULL}。
      *

+ 28 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarTestAuth.java

@@ -8,11 +8,15 @@ import java.util.List;
 import java.util.function.Supplier;
 
 /**
- * 测试专用:在 SecurityContext 里放一个「某租户的登录身份」。
+ * 测试专用:在 SecurityContext 里放一个「某租户的登录身份」或「超管身份」。
  *
  * 租户级寻址(去企业化)取的是 {@code SecurityContextHolder} 里的 LoginUser.tenantId,
  * 而这些服务全是纯 Mockito 单测、没有 Spring 上下文,所以要自己把身份摆进去。
  * 用完必须还原,否则会顺着线程漏进后面的用例。
+ *
+ * ★ 超管腿(2026-10-09 超管建店)看的是 {@code LoginUser.isSuperuser} —— 与
+ * {@code TenantInnerInterceptor} 旁路用的是**同一个开关**,且它与 tenantId 是
+ * 两个独立字段:「超管 + 租户为 null」是常规组合,「非超管 + 租户为 null」也真实存在。
  */
 final class HarTestAuth {
 
@@ -38,4 +42,27 @@ final class HarTestAuth {
             return null;
         });
     }
+
+    /** 超管身份:{@code isSuperuser=true}、租户为 null(「超管没有自己的租户」的常规组合) */
+    static <T> T asSuperuser(Supplier<T> action) {
+        return asSuperuser(null, action);
+    }
+
+    /**
+     * 带租户的超管 —— {@code sys_user.tenant_id} 可空也能有值,两个字段独立。
+     * 用于钉「超管不得拿自己的租户当回退」这类用例。
+     */
+    static <T> T asSuperuser(Long tenantId, Supplier<T> action) {
+        var previous = SecurityContextHolder.getContext().getAuthentication();
+        LoginUser user = new LoginUser();
+        user.setTenantId(tenantId);
+        user.setIsSuperuser(true);
+        SecurityContextHolder.getContext().setAuthentication(
+                new UsernamePasswordAuthenticationToken(user, null, List.of()));
+        try {
+            return action.get();
+        } finally {
+            SecurityContextHolder.getContext().setAuthentication(previous);
+        }
+    }
 }