Просмотр исходного кода

test(contract): TDD-RED supplier.inventory.manage (SC-S-05) - S-cfc-239

为能力键 supplier.inventory.manage 写契约级单测(预期红,TDD-RED 阶段)。
溯源:
- 场景 SC-S-05:库存管理(调库存→同步,负库存拦截)
- 用例 TC-S-05 / TC-S-05-E①② / TC-S-05-P
- 设计 API_REFERENCE §4.13 + AdminInventoryController /api/admin/inventory

新增测试 12 例(纯 Mockito/反射,无 Spring/DB/外部服务):
- 绿基线(6):结构锚点 2、入库同步 1、供应商流水读侧隔离 2、超库存拦截 1
- 红契约(6):
  * inbound_negativeItemQuantity_rejected: 入库明细负数量应 400 拦截
  * counting_negativeActualStock_rejected: 盘点负实盘应 400 拦截
  * countingConfirm_neverPersistsNegativeStock: 盘点确认后库存不得为负
  * manualOutbound_usesAtomicGuardedDecrement: 手动出库应走 decreaseStockAtomic 原子条件扣减
  * inventoryWriteEndpoints_haveSupplierScopeGuard: 写操作端点缺少 @RequestAttribute("roles") 跨商户隔离
  * inventoryWriteEndpoints_haveRoleGuard: 写操作端点缺少 @RequestAttribute("role") 角色守卫

红测结果:Tests run 12, Failures 6, Errors 0(6 红 = 上述契约缺口)。
E2E Test Bot 3 часов назад
Родитель
Сommit
7734edce5c

+ 464 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierInventoryManageContractTest.java

@@ -0,0 +1,464 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminInventoryController;
+import com.etotem.cfc.entity.InventoryCountingRecord;
+import com.etotem.cfc.entity.InventoryInboundItem;
+import com.etotem.cfc.entity.InventoryInboundOrder;
+import com.etotem.cfc.entity.InventoryTransaction;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.mapper.InventoryCountingRecordMapper;
+import com.etotem.cfc.mapper.InventoryInboundItemMapper;
+import com.etotem.cfc.mapper.InventoryInboundOrderMapper;
+import com.etotem.cfc.mapper.InventoryTransactionMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.ProductSkuMapper;
+import com.etotem.cfc.service.ComboService;
+import com.etotem.cfc.service.InventoryService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:商品服务商库存管理(能力键 {@code supplier.inventory.manage},TDD-RED 阶段)。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>场景:{@code tests/user-scenarios/应用场景总览.md} SC-S-05(库存管理)
+ *       ——「目标:保障供应。流程:调库存 → 同步。边界:负库存拦截。模块:库存服务。」;</li>
+ *   <li>用例:{@code tests/test-cases/vendor.md}
+ *       <b>TC-S-05</b>(调库存→同步;负库存拦截;同步后前台可见)、
+ *       <b>TC-S-05-E</b>(① 调库存为负 → 400 拦截;② 并发扣减超卖保护不出现负库存;
+ *       ③ 同步失败重试前台一致)、
+ *       <b>TC-S-05-P</b>(非本服务商资源操作被拒;跨商户数据隔离);</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 +
+ *       {@code AdminInventoryController} {@code /api/admin/inventory}
+ *       (入库单 / 库存流水 / 盘点 / 库存预警 / 商品库存详情 / 手动出库 / 套餐商品);</li>
+ *   <li>任务登记:sc-key-map.md「SC-S-05 → supplier.inventory.manage → S-cfc-239」。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为与「实现差距」(RED 依据)</b></p>
+ * <ol>
+ *   <li><b>正常流(结构锚点,绿基线)</b>:
+ *       {@code AdminInventoryController} 暴露 13 个约定 POST 端点(入库/流水/盘点/预警/库存详情/出库)。</li>
+ *   <li><b>正常流(行为,绿基线)</b>:
+ *       {@link InventoryService#confirmInbound} 确认后商品主库存同步 +quantity 且写入 {@code inbound} 流水
+ *       (TC-S-05:调库存 → 同步后前台可见);</li>
+ *   <li><b>权限流·读侧(绿基线,B-5)</b>:
+ *       {@link AdminInventoryController#listTransactions} 对纯供应商
+ *       ({@code @RequestAttribute("roles") = [supplier]})传入 {@code supplierUserId} 行级隔离;
+ *       对平台角色(admin)不施加供应商范围(当前实现已具备该分流)。</li>
+ *   <li><b>边界流(RED)· 负库存拦截(TC-S-05-E① / SC-S-05 边界)</b>:
+ *       <ul>
+ *         <li>入库明细数量为负应 400 拦截——当前 {@code createInboundOrder} 仅校验 items 非空,
+ *             不校验单条 {@code quantity > 0};</li>
+ *         <li>盘点实盘数为负应 400 拦截——当前 {@code createCounting} 仅校验 {@code actualStock} 非空;</li>
+ *         <li>盘点确认后商品库存不得为负——当前 {@code confirmCounting} 直接把
+ *             {@code product.stock = actualStock} 落库(盘盈差为负时产生负库存)。</li>
+ *       </ul></li>
+ *   <li><b>边界流(RED)· 并发超卖保护(TC-S-05-E②)</b>:手动出库应使用条件原子扣减
+ *       {@code ProductMapper.decreaseStockAtomic}({@code UPDATE ... SET stock=stock-qty WHERE stock >= qty});
+ *       当前 {@code createManualOutbound} 为「读改写」非原子实现({@code selectById → setStock → updateById}),
+ *       并发下两个请求都可读过充足库存后互相覆盖 → 超卖。</li>
+ *   <li><b>权限流(RED)· 写侧跨商户隔离(TC-S-05-P)</b>:
+ *       入库/盘点/出库/商品库存详情等写操作端点应携带 {@code @RequestAttribute("roles")} 做供应商范围校验
+ *       (复用 B-5 的模式),当前仅 {@code /transactions/list} 一处传递 {@code roles},写入口径全缺失——
+ *       纯供应商可对他商商品执行入库/盘点/出库;且全控制器无 {@code @RequestAttribute("role")} 角色守卫,
+ *       非 admin 不能 403 拒绝(对标 {@code AdminAuditController}/{@code AdminArticleController} 模式)。</li>
+ * </ol>
+ *
+ * <p><b>RED 预期</b>:结构锚点 + 入库同步 + 供应商流水读侧隔离用例通过(绿,基线);
+ * 负库存拦截 / 并发超卖保护 / 写入口径 roles 范围校验 / role 守卫用例断言失败(红),
+ * 断言原文清晰标注契约所需的「400 拦截」「原子条件扣减」「roles 范围校验」「role 守卫」缺失。
+ * GREEN 阶段实现后本测试自动转绿。
+ *
+ * <p><b>说明</b>:TC-S-05-E③「同步失败重试前台一致」为集成/E2E 口径({@code tests/e2e}),
+ * 本契约单测不覆盖。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito + 反射,无 Spring 上下文 / 无 DB / 无外部服务。</p>
+ */
+@SuppressWarnings("unchecked")
+class SupplierInventoryManageContractTest extends EnergyContractTestBase {
+
+    // ==================== 被校验对象 ====================
+
+    private AdminInventoryController controller;
+
+    /** 契约行为验证用真实 Service(注入 mock Mapper) */
+    private InventoryService service;
+    @Mock
+    private ProductMapper productMapper;
+    @Mock
+    private ProductSkuMapper productSkuMapper;
+    @Mock
+    private InventoryTransactionMapper transactionMapper;
+    @Mock
+    private InventoryInboundOrderMapper inboundOrderMapper;
+    @Mock
+    private InventoryInboundItemMapper inboundItemMapper;
+    @Mock
+    private InventoryCountingRecordMapper countingRecordMapper;
+
+    /** Controller 层 B-5 行级隔离分流验证用 mock Service */
+    @Mock
+    private InventoryService controllerService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        initTable(Product.class);
+        initTable(ProductSku.class);
+        initTable(InventoryTransaction.class);
+        initTable(InventoryInboundItem.class);
+        initTable(InventoryInboundOrder.class);
+        MockitoAnnotations.openMocks(this);
+
+        // 真实 InventoryService:验证契约行为(负库存拦截 / 原子扣减 / 入库同步)
+        service = new InventoryService();
+        inject(service, "productMapper", productMapper);
+        inject(service, "productSkuMapper", productSkuMapper);
+        inject(service, "transactionMapper", transactionMapper);
+        inject(service, "inboundOrderMapper", inboundOrderMapper);
+        inject(service, "inboundItemMapper", inboundItemMapper);
+        inject(service, "countingRecordMapper", countingRecordMapper);
+
+        // Controller:注入 mock service,聚焦层间契约
+        controller = new AdminInventoryController();
+        inject(controller, "inventoryService", controllerService);
+        inject(controller, "comboService", org.mockito.Mockito.mock(ComboService.class));
+    }
+
+    // ==================== 正常流(结构锚点,SC-S-05:调库存 → 同步)====================
+
+    @Test
+    @DisplayName("SC-S-05 正常流(结构锚点):AdminInventoryController 应暴露入库维护 POST 端点")
+    void controller_exposesInboundEndpoints() {
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/detail"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/detail");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/confirm"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/confirm");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/inbound/cancel"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/inbound/cancel");
+    }
+
+    @Test
+    @DisplayName("SC-S-05 正常流(结构锚点):AdminInventoryController 应暴露流水/盘点/预警/库存/出库 POST 端点")
+    void controller_exposesTransactionsCountingOutboundEndpoints() {
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/transactions/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/transactions/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/confirm"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/confirm");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/counting/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/counting/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/alert/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/alert/list");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/product/stock"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/product/stock");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/outbound/create"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/outbound/create");
+        assertTrue(hasEndpoint(AdminInventoryController.class, "/outbound/list"),
+                "RED[supplier.inventory.manage] 应暴露 POST /api/admin/inventory/outbound/list");
+    }
+
+    // ==================== 正常流(行为,SC-S-05:调库存 → 同步)====================
+
+    @Test
+    @DisplayName("SC-S-05 正常流(行为):确认入库应同步商品库存并写入 inbound 流水")
+    void inboundConfirm_syncsStockAndWritesTransaction() {
+        Long orderId = 1L;
+        Long productId = 1L;
+        int qty = 4;
+
+        InventoryInboundOrder order = new InventoryInboundOrder();
+        order.setId(orderId);
+        order.setStatus("pending");
+        when(inboundOrderMapper.selectById(orderId)).thenReturn(order);
+
+        InventoryInboundItem item = new InventoryInboundItem();
+        item.setProductId(productId);
+        item.setQuantity(qty);
+        when(inboundItemMapper.selectList(any())).thenReturn(Collections.singletonList(item));
+
+        Product product = new Product();
+        product.setId(productId);
+        product.setStock(10);
+        when(productMapper.selectById(productId)).thenReturn(product);
+
+        Result<String> result = service.confirmInbound(orderId, 100L);
+
+        assertNotNull(result);
+        assertEquals(200, result.getCode(),
+                "RED[supplier.inventory.manage] 确认入库应成功(TC-S-05 流程:调库存→同步),当前 code="
+                        + result.getCode());
+        assertEquals(Integer.valueOf(10 + qty), product.getStock(),
+                "RED[supplier.inventory.manage] 入库后商品主库存应同步为 10+" + qty
+                        + "(同步后前台可见),实际=" + product.getStock());
+
+        ArgumentCaptor<InventoryTransaction> captor = ArgumentCaptor.forClass(InventoryTransaction.class);
+        verify(transactionMapper).insert(captor.capture());
+        InventoryTransaction tx = captor.getValue();
+        assertEquals("inbound", tx.getType(), "RED[supplier.inventory.manage] 入库流水 type 应为 inbound");
+        assertEquals(Integer.valueOf(1), tx.getDirection(),
+                "RED[supplier.inventory.manage] 入库流水 direction 应为 +1");
+        assertEquals(Integer.valueOf(10), tx.getBeforeStock(),
+                "RED[supplier.inventory.manage] 入库流水 beforeStock 应为 10");
+        assertEquals(Integer.valueOf(14), tx.getAfterStock(),
+                "RED[supplier.inventory.manage] 入库流水 afterStock 应为 14");
+    }
+
+    // ==================== 边界流(TC-S-05-E① / SC-S-05 边界):负库存拦截 ====================
+
+    @Test
+    @DisplayName("TC-S-05-E① 边界:入库明细数量为负应 400 拦截,不得创建入库单")
+    void inbound_negativeItemQuantity_rejected() {
+        Map<String, Object> badItem = new HashMap<>();
+        badItem.put("productId", 1L);
+        badItem.put("quantity", -5);
+
+        Result<Map<String, Object>> result = service.createInboundOrder(
+                9L, "供应商A", Collections.singletonList(badItem), 100L, null, null, null);
+
+        assertNotNull(result);
+        assertEquals(400, result.getCode(),
+                "RED[supplier.inventory.manage] 入库明细数量为负(-5)应 400 拦截(TC-S-05-E① 负库存拦截),"
+                        + "当前 code=" + result.getCode()
+                        + "(createInboundOrder 仅校验 items 非空,未校验单条 quantity>0)");
+        verify(inboundOrderMapper, never()).insert(any());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E①/SC-S-05 边界:盘点实盘数为负应 400 拦截,不得创建盘点记录")
+    void counting_negativeActualStock_rejected() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(10);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createCounting(1L, null, -5, 100L, "盘亏");
+
+        assertNotNull(result);
+        assertEquals(400, result.getCode(),
+                "RED[supplier.inventory.manage] 盘点实盘数为负(-5)应 400 拦截(SC-S-05 边界:负库存拦截),"
+                        + "当前 code=" + result.getCode()
+                        + "(createCounting 仅校验 actualStock 非空,未校验 actualStock>=0)");
+        verify(countingRecordMapper, never()).insert(any());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E① 边界:盘点确认后商品库存不得为负")
+    void countingConfirm_neverPersistsNegativeStock() {
+        InventoryCountingRecord record = new InventoryCountingRecord();
+        record.setId(50L);
+        record.setProductId(1L);
+        record.setExpectedStock(10);
+        record.setActualStock(-3);
+        record.setDifference(-13);
+        record.setStatus("pending");
+        when(countingRecordMapper.selectById(50L)).thenReturn(record);
+
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(10);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.confirmCounting(50L, 100L);
+
+        assertNotNull(result);
+        assertTrue(product.getStock() != null && product.getStock() >= 0,
+                "RED[supplier.inventory.manage] 盘点确认后商品库存不得为负(TC-S-05-E① 负库存拦截),"
+                        + "当前 stock=" + product.getStock()
+                        + "(confirmCounting 直接把 product.stock=actualStock 落库,未做下界保护)");
+    }
+
+    // ==================== 边界流(TC-S-05-E②):并发超卖保护 ====================
+
+    @Test
+    @DisplayName("TC-S-05-E② 边界:手动出库应走条件原子扣减 decreaseStockAtomic(WHERE stock>=qty)")
+    void manualOutbound_usesAtomicGuardedDecrement() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(5);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createManualOutbound(1L, null, 3, "调拨", 100L, null, null);
+
+        assertNotNull(result);
+        assertEquals(200, result.getCode(),
+                "RED[supplier.inventory.manage] 库存充足时手动出库应成功,当前 code=" + result.getCode());
+        // 契约要求:出库必须走 ProductMapper.decreaseStockAtomic(已存在的方法:UPDATE ... WHERE stock >= qty)。
+        // 当前实现为 selectById → setStock → updateById 读改写,非原子。
+        verify(productMapper).decreaseStockAtomic(1L, 3);
+    }
+
+    @Test
+    @DisplayName("TC-S-05-E② 边界:出库超库存时应失败且不写流水(单线程守卫已存在,测试不回归)")
+    void manualOutbound_overStock_failsWithoutTransaction() {
+        Product product = new Product();
+        product.setId(1L);
+        product.setStock(2);
+        when(productMapper.selectById(1L)).thenReturn(product);
+
+        Result<String> result = service.createManualOutbound(1L, null, 5, "调拨", 100L, null, null);
+
+        assertNotNull(result);
+        assertTrue(result.getCode() != 200,
+                "RED[supplier.inventory.manage] 出库量(5) 超过库存(2) 应失败(TC-S-05-E② 不出现负库存),"
+                        + "当前 code=" + result.getCode());
+        verify(transactionMapper, never()).insert(any());
+    }
+
+    // ==================== 权限流(TC-S-05-P):跨商户隔离 + 角色守卫 ====================
+
+    @Test
+    @DisplayName("TC-S-05-P 权限(绿基线):纯供应商查库存流水应传 supplierUserId 做行级隔离")
+    void listTransactions_supplierOnly_passesSupplierScope() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 20);
+
+        when(controllerService.listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), eq(99L)))
+                .thenReturn(Result.success(new HashMap<>()));
+
+        Result<Map<String, Object>> result = controller.listTransactions(params, 99L,
+                Collections.singletonList("supplier"));
+
+        assertNotNull(result);
+        verify(controllerService).listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), eq(99L));
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P 权限(绿基线):平台角色查库存流水不应施加供应商范围限制")
+    void listTransactions_admin_notScopedToSupplier() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("page", 1);
+        params.put("size", 20);
+
+        when(controllerService.listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), isNull()))
+                .thenReturn(Result.success(new HashMap<>()));
+
+        Result<Map<String, Object>> result = controller.listTransactions(params, 1L,
+                Collections.singletonList("admin"));
+
+        assertNotNull(result);
+        verify(controllerService).listTransactions(eq(1), eq(20), isNull(), isNull(),
+                isNull(), isNull(), isNull(), isNull());
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P/SC-S-05-P 权限:库存写操作端点应带 @RequestAttribute(\"roles\") 跨商户隔离参数")
+    void inventoryWriteEndpoints_haveSupplierScopeGuard() {
+        String[] writeOrStockPaths = {
+                "/inbound/create", "/inbound/confirm", "/inbound/cancel",
+                "/counting/create", "/counting/confirm",
+                "/outbound/create", "/product/stock"
+        };
+        List<String> missing = new ArrayList<>();
+        for (String path : writeOrStockPaths) {
+            if (!hasRolesParam(AdminInventoryController.class, path)) {
+                missing.add(path);
+            }
+        }
+        assertTrue(missing.isEmpty(),
+                "RED[supplier.inventory.manage] 以下库存端点缺少 @RequestAttribute(\"roles\") 跨商户隔离参数"
+                        + "(TC-S-05-P:非本服务商资源操作被拒 / 跨商户数据隔离),纯供应商可对他商商品执行入库/盘点/出库:"
+                        + missing
+                        + ";当前仅 /transactions/list 落地了 supplierUserId 行级隔离(B-5),写入口径缺失。");
+    }
+
+    @Test
+    @DisplayName("TC-S-05-P/SC-S-05-P 权限:库存写操作端点应带 @RequestAttribute(\"role\") 角色守卫")
+    void inventoryWriteEndpoints_haveRoleGuard() {
+        assertTrue(hasRoleGuard(AdminInventoryController.class),
+                "RED[supplier.inventory.manage] AdminInventoryController 写操作应带"
+                        + " @RequestAttribute(\"role\") 守卫,非 admin 访问 403(库存维护属管理端接口,§4.13);"
+                        + "当前仅有 JwtInterceptor 通用鉴权,无角色级二次校验(对标 AdminAuditController/AdminArticleController 模式)。");
+    }
+
+    // ==================== 反射辅助 ====================
+
+    /** 控制器是否暴露指定 POST 路径端点 */
+    private boolean hasEndpoint(Class<?> controller, String path) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                for (String v : pm.value()) {
+                    if (path.equals(v)) {
+                        return true;
+                    }
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 控制器任意方法是否带 @RequestAttribute("role") 守卫 */
+    private boolean hasRoleGuard(Class<?> controller) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "role".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    /** 指定 POST 路径的方法是否携带 @RequestAttribute("roles") 跨商户隔离参数 */
+    private boolean hasRolesParam(Class<?> controller, String path) {
+        for (java.lang.reflect.Method m : controller.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm == null) {
+                continue;
+            }
+            boolean isTarget = false;
+            for (String v : pm.value()) {
+                if (path.equals(v)) {
+                    isTarget = true;
+                    break;
+                }
+            }
+            if (!isTarget) {
+                continue;
+            }
+            for (java.lang.reflect.Parameter p : m.getParameters()) {
+                RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+                if (ra != null && "roles".equals(ra.value())) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+}