Forráskód Böngészése

feat(统一支付): 菜单权限 SQL 与接线测试

alphaH 1 hete
szülő
commit
c554e708f7

+ 221 - 0
java/src/main/resources/db/unified-menu.sql

@@ -0,0 +1,221 @@
+-- 统一支付平台 —— 菜单与权限(Task 17,2026-10-20)
+--
+-- 【本脚本尚未执行】执行由用户做。执行前先跑文末「执行前检查」两条只读查询。
+--
+-- ── 本脚本干什么 ──────────────────────────────────────────────────────
+-- 新建一个**顶层目录**(type=1)「统一支付平台」,其下:
+--   · 3 行页面(type=2):商户配置 / 订单管理 / 收银台
+--   · 4 行按钮(type=3):订单详情 / 同步上游 / 发起退款 / 发起收款
+-- 并把**目录 + 三页 + 四按钮**(8 行)授权给 role 1(管理员)与 role 2(租户角色)。
+--   ★ 两页中的「商户配置」只有列表权限(租户看自己的行);
+--     商户新增/删除是**超管身份**门禁(`UnifiedMerchantController#save/delete`
+--     的 `@perm.hasRole('SUPERUSER')`),**不建权限行**,也不在本脚本授权范围内。
+--
+-- ── permission 必须与 @PreAuthorize 逐字相同 ──────────────────────────
+-- 差一个字符就是 403,而且**超管测不出来**(`PermissionCheck` 对 `ROLE_SUPERUSER` 直接放行)。
+-- 本脚本 7 个 permission 逐字取自 2026-10-20 的源码(BASE 28f4899b,未抄任何文档):
+--   UnifiedMerchantController  @RequestMapping("/payment/unified/merchant")
+--     list   : @perm.hasAny('module_payment:unified:merchant:list')
+--     save   : @perm.hasRole('SUPERUSER')          ← 不建权限行
+--     delete : @perm.hasRole('SUPERUSER')          ← 不建权限行
+--   UnifiedOrderController     @RequestMapping("/payment/unified/order")
+--     list      : @perm.hasAny('module_payment:unified:order:list')
+--     detail    : @perm.hasAny('module_payment:unified:order:detail')
+--     refunds   : @perm.hasAny('module_payment:unified:order:detail')   ← 复用 detail
+--     sync      : @perm.hasAny('module_payment:unified:order:sync')
+--     refund    : @perm.hasAny('module_payment:unified:order:refund')
+--   UnifiedCashierController   @RequestMapping("/payment/unified/cashier")
+--     create    : @perm.hasAny('module_payment:unified:cashier:create')
+--     snapshot  : @perm.hasAny('module_payment:unified:cashier:list')
+-- ★ 取值口径是「现取」,不是「抄一次就完」—— 控制器改了这里必须重取。
+-- ★ 钉子:`UnifiedAdminWiringTest` 双向钉住「注解集合 == SQL 集合 == 这 7 个串」。
+--
+-- ── 表结构约定(照抄踩过的坑)────────────────────────────────────────
+--   · `component_path` 是**相对 `src/views/` 的路径**:不带 `views/` 前缀、不带 `.vue` 后缀。
+--     本脚本的值与预期的页面文件对齐:
+--       frontend/src/views/module_payment/unified/{merchant,order,cashier}/index.vue
+--     写错会拼成查不到的模块名 ⇒ **静默落到 error/404.vue**(不是报错)。
+--     ★ 页面文件由 Tasks 19-21 创建;本脚本执行时**不**检查文件是否存在。
+--   · 目录行本身也必须在 `sys_role_menus` 里(`UserService.buildMenuTree` 只会把 parent
+--     同在集合内的节点挂上树;父不在集合里的子节点被**直接丢弃**,不报错)。
+--   · 用 `MAX("order") + 1` 现算,不写死数字:目录取顶层 MAX+1;页面在目录下 1/2/3;
+--     按钮在各自页面下 1/2/3 与 1。`sys_menu` 对 (parent_id, "order") 没有唯一约束
+--     ⇒ 撞序不报错,只是两行同序。
+--
+-- ── ★★ 幂等形状(R49):不依赖上一段的 RETURNING ───────────────────────
+-- `har-cashier-menu.sql` 用 `WITH page AS (INSERT … RETURNING id)`,其缺点是
+-- 「页面已存在(未插入)⇒ `page` 为空 ⇒ 按钮与授权一起不插」,无法补齐半套数据。
+-- 本脚本改为**每段独立**:插入后用**稳定锚**从 `sys_menu` 重新选出这些行:
+--   · 目录锚 `route_name='payment-unified'`(身份),不锚 `name`(标签可被人手工改)
+--   · 页面锚 `route_path`
+--   · 按钮锚 `(parent_id, permission)`
+--   · 授权锚 `(role_id, menu_id)`
+-- ⇒ 「目录在、页面缺」「页面在、按钮/授权缺」等半套状态重跑都能补齐,且不插重复行、不报错。
+--
+-- ── uuid / id ────────────────────────────────────────────────────────
+-- 每段 `INSERT INTO sys_menu` 都显式给 `uuid` 赋 `gen_random_uuid()`(SELECT 每输出一行
+-- 求值一次);`id` 省略,由列默认值生成。
+-- `created_time/updated_time = CURRENT_TIMESTAMP`,`status='0'`。
+--
+-- ── ★ 已知未覆盖(如实)──────────────────────────────────────────────
+--   · `route_name` 与前端 SFC `defineOptions.name` 的一致性、`component_path` 指向的文件
+--     是否存在:Task 17 不检查(页面尚未创建),由 Task 22 的 `verify-unified-menu.mjs` 覆盖。
+--   · 本脚本只做静态断言(`UnifiedAdminWiringTest`),未在数据库执行过。
+
+-- ══════════════════════════════════════════════════════════════════════
+-- 1) 顶层目录:统一支付平台
+-- ══════════════════════════════════════════════════════════════════════
+INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                      redirect, hidden, keep_alive, always_show, affix, title, params,
+                      uuid, parent_id, status, created_time, updated_time)
+SELECT '统一支付平台', 1,
+       (SELECT COALESCE(MAX("order"), 0) + 1 FROM sys_menu WHERE parent_id IS NULL),
+       NULL, 'money',
+       'payment-unified', '/payment/unified', NULL,
+       NULL, false, false, false, false, '统一支付平台', NULL,
+       gen_random_uuid(), NULL, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ WHERE NOT EXISTS (SELECT 1 FROM sys_menu
+                    WHERE route_name = 'payment-unified' AND type = 1
+                      AND status = '0' AND parent_id IS NULL);
+
+-- ══════════════════════════════════════════════════════════════════════
+-- 2) 三个页面(type=2):商户配置 / 订单管理 / 收银台
+--    父目录按 route_name 从 sys_menu 重新选出;页面按 route_path 判重。
+-- ══════════════════════════════════════════════════════════════════════
+INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                      redirect, hidden, keep_alive, always_show, affix, title, params,
+                      uuid, parent_id, status, created_time, updated_time)
+SELECT v.name, 2, v.ord, v.perm, NULL, v.route_name, v.route_path, v.component_path,
+       NULL, false, true, false, false, v.name, NULL,
+       gen_random_uuid(), dir.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+  FROM (SELECT id FROM sys_menu
+         WHERE route_name = 'payment-unified' AND type = 1
+           AND status = '0' AND parent_id IS NULL) AS dir
+ CROSS JOIN (VALUES
+       ('商户配置', 1, 'module_payment:unified:merchant:list',
+        'payment-unified-merchant', '/payment/unified/merchant',
+        'module_payment/unified/merchant/index'),
+       ('订单管理', 2, 'module_payment:unified:order:list',
+        'payment-unified-order', '/payment/unified/order',
+        'module_payment/unified/order/index'),
+       ('收银台',   3, 'module_payment:unified:cashier:list',
+        'payment-unified-cashier', '/payment/unified/cashier',
+        'module_payment/unified/cashier/index')
+     ) AS v(name, ord, perm, route_name, route_path, component_path)
+ WHERE NOT EXISTS (SELECT 1 FROM sys_menu m
+                    WHERE m.route_path = v.route_path AND m.type = 2 AND m.status = '0');
+
+-- ══════════════════════════════════════════════════════════════════════
+-- 3) 四个按钮(type=3):订单详情 / 同步上游 / 发起退款 / 发起收款
+--    父页面按 route_path 从 sys_menu 重新选出;按钮按 (parent_id, permission) 判重。
+--    ★ 漏了某行 ⇒ 非超管点对应按钮必 403,而超管测不出来。
+-- ══════════════════════════════════════════════════════════════════════
+INSERT INTO sys_menu (name, type, "order", permission, icon, route_name, route_path, component_path,
+                      redirect, hidden, keep_alive, always_show, affix, title, params,
+                      uuid, parent_id, status, created_time, updated_time)
+SELECT v.name, 3, v.ord, v.perm, NULL, NULL, NULL, NULL,
+       NULL, false, false, false, false, v.name, NULL,
+       gen_random_uuid(), p.id, '0', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+  FROM (VALUES
+       ('订单详情', 1, 'module_payment:unified:order:detail', '/payment/unified/order'),
+       ('同步上游', 2, 'module_payment:unified:order:sync',   '/payment/unified/order'),
+       ('发起退款', 3, 'module_payment:unified:order:refund', '/payment/unified/order'),
+       ('发起收款', 1, 'module_payment:unified:cashier:create', '/payment/unified/cashier')
+     ) AS v(name, ord, perm, page_path)
+  JOIN sys_menu p ON p.route_path = v.page_path AND p.type = 2 AND p.status = '0'
+ WHERE NOT EXISTS (SELECT 1 FROM sys_menu b
+                    WHERE b.parent_id = p.id AND b.permission = v.perm
+                      AND b.type = 3 AND b.status = '0');
+
+-- ══════════════════════════════════════════════════════════════════════
+-- 4) 授权:目录 + 三页 + 四按钮(8 行)× role 1 与 role 2
+--    三类锚**一次**选出 8 行,再 CROSS JOIN 两个角色;按 (role_id, menu_id) 判重。
+--    ★ 目录行必须授:`buildMenuTree` 会静默丢弃父不在集合里的子节点。
+-- ══════════════════════════════════════════════════════════════════════
+INSERT INTO sys_role_menus (role_id, menu_id)
+SELECT r.role_id, m.id
+  FROM (SELECT id FROM sys_menu
+         WHERE status = '0'
+           AND ((route_name = 'payment-unified' AND type = 1)
+             OR (route_path IN ('/payment/unified/merchant',
+                                '/payment/unified/order',
+                                '/payment/unified/cashier') AND type = 2)
+             OR (permission IN ('module_payment:unified:order:detail',
+                                'module_payment:unified:order:sync',
+                                'module_payment:unified:order:refund',
+                                'module_payment:unified:cashier:create') AND type = 3))
+       ) AS m
+ CROSS JOIN (VALUES (1), (2)) AS r(role_id)
+ WHERE NOT EXISTS (SELECT 1 FROM sys_role_menus rm
+                    WHERE rm.role_id = r.role_id AND rm.menu_id = m.id);
+
+
+-- ── 执行前检查(**先跑这两条**)─────────────────────────────────────────
+--
+-- 1) 目录是否已存在(已存在时第 1 段不插,其余段照常补齐缺失的页面/按钮/授权)
+--   SELECT id, name, title, type, route_name, route_path FROM sys_menu
+--    WHERE route_name = 'payment-unified' AND type = 1 AND status = '0' AND parent_id IS NULL;
+--   期望(首次执行前):**0 行**。若 1 行,记下 id —— 执行后核对同一行。
+--
+-- 2) role 1 / role 2 是否存在(授权段引用的两个角色;缺哪个,那个角色的授权行就插不出来)
+--   SELECT id, name, code FROM sys_role WHERE id IN (1, 2) ORDER BY id;
+--   期望:**2 行**(id=1 管理员 / id=2 租户角色)。
+--
+-- ── 只读核对(**执行后**手工跑一遍)─────────────────────────────────────
+--
+-- 3) 目录:期望 **1 行**
+--   SELECT id, name, title, type, "order", route_name, route_path, uuid
+--     FROM sys_menu
+--    WHERE route_name = 'payment-unified' AND type = 1 AND status = '0' AND parent_id IS NULL;
+--   期望:type=1,route_path='/payment/unified',uuid 非空(36 字符)。
+--
+-- 4) 页面:期望 **3 行**
+--   SELECT id, name, type, "order", permission, route_name, route_path, component_path,
+--          keep_alive, uuid
+--     FROM sys_menu
+--    WHERE route_path IN ('/payment/unified/merchant',
+--                         '/payment/unified/order',
+--                         '/payment/unified/cashier')
+--      AND type = 2 AND status = '0'
+--    ORDER BY "order";
+--   期望 3 行:
+--     商户配置 order=1 route_name='payment-unified-merchant' permission='module_payment:unified:merchant:list'
+--     订单管理 order=2 route_name='payment-unified-order'    permission='module_payment:unified:order:list'
+--     收银台   order=3 route_name='payment-unified-cashier'  permission='module_payment:unified:cashier:list'
+--   三行 keep_alive=true、uuid 非空、component_path 分别为
+--     module_payment/unified/merchant/index 等(相对 views/,无前缀无后缀)。
+--
+-- 5) 按钮:期望 **4 行**
+--   SELECT m.id, m.name, m.type, m."order", m.permission, p.route_path
+--     FROM sys_menu m JOIN sys_menu p ON m.parent_id = p.id
+--    WHERE m.type = 3 AND m.status = '0'
+--      AND p.route_path IN ('/payment/unified/order', '/payment/unified/cashier')
+--    ORDER BY p.route_path, m."order";
+--   期望 4 行:
+--     /payment/unified/order    order=1 订单详情 module_payment:unified:order:detail
+--     /payment/unified/order    order=2 同步上游 module_payment:unified:order:sync
+--     /payment/unified/order    order=3 发起退款 module_payment:unified:order:refund
+--     /payment/unified/cashier  order=1 发起收款 module_payment:unified:cashier:create
+--
+-- 6) 授权:期望 **16 行**(2 个角色 × 8 行:目录 1 + 页面 3 + 按钮 4)
+--   SELECT rm.role_id, m.id, m.name, m.type, m.permission
+--     FROM sys_role_menus rm JOIN sys_menu m ON m.id = rm.menu_id
+--    WHERE m.status = '0'
+--      AND ((m.route_name = 'payment-unified' AND m.type = 1)
+--        OR (m.route_path IN ('/payment/unified/merchant',
+--                             '/payment/unified/order',
+--                             '/payment/unified/cashier') AND m.type = 2)
+--        OR (m.permission IN ('module_payment:unified:order:detail',
+--                             'module_payment:unified:order:sync',
+--                             'module_payment:unified:order:refund',
+--                             'module_payment:unified:cashier:create') AND m.type = 3))
+--    ORDER BY rm.role_id, m.type, m."order";
+--   期望:role 1 **8 行**、role 2 **8 行**,合计 16 行。
+--   ★ 判据不是「总行数对不对」,而是**两个角色各 8 行都在**:
+--     少目录行 ⇒ 该角色整棵子树被 `buildMenuTree` 静默丢弃;
+--     少按钮行 ⇒ 非超管点对应按钮必 403,而超管测不出来。
+--
+-- 7) 端到端(**唯一能证明权限串没写错的一条**):用一个**非超管**账号登录,
+--    确认 7 个 `module_payment:unified:*` 都出现在
+--    `MenuMapper.selectPermissionsByUserId` 的返回里。
+--    ★ 超管账号**证明不了任何事** —— 它对 ROLE_SUPERUSER 直接放行。

+ 315 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/UnifiedAdminWiringTest.java

@@ -0,0 +1,315 @@
+package com.payment.platform.module.payment.unified.controller;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 统一支付的**管理端菜单契约**接线守卫(Task 17)。
+ *
+ * <p>这里钉的是三份事实之间的一致性:控制器 {@code @PreAuthorize} 里的权限串、
+ * {@code db/unified-menu.sql} 里的 permission 行、以及提交给用户的授权范围。
+ * 三者靠的是**逐字相等**({@code PermissionCheck.hasAny} 是集合全等匹配),
+ * 差一个字符就是 403;而**超管测不出来**({@code PermissionCheck} 对
+ * {@code ROLE_SUPERUSER} 直接放行),所以只能靠静态断言兜住。
+ *
+ * <p>★ 本类**不**断言前端文件:{@code route_name} 与 SFC {@code defineOptions} 一致、
+ * {@code component_path} 指向的文件存在,这两条依赖 Tasks 19-21 才创建的页面文件,
+ * Task 17 执行时断言必红。它们由 Task 22 的 {@code verify-unified-menu.mjs} 覆盖(R27)。
+ *
+ * <p>★ 读 SQL 前**先剥行注释**:本脚本的注释里有大量逐字引用的权限串与示例查询,
+ * 不剥的话「SQL 里的串都在注解里」会被注释兜住,等于把一个授不出来的串判成合规。
+ */
+class UnifiedAdminWiringTest {
+
+    /** 菜单脚本在 classpath 上的位置(`src/main/resources/db/`)。 */
+    private static final String MENU_SQL = "db/unified-menu.sql";
+
+    /**
+     * 权限串的取数式(与 {@code LeshouyingAdminWiringTest#permissionsFromMenuSql} 同形):
+     * 小写开头、至少三段冒号分隔。只认引号里的值,故路径/路由名不会混进来。
+     */
+    private static final Pattern PERMISSION = Pattern.compile("'([a-z][a-z0-9_]*(?::[a-z0-9_]+){2,})'");
+
+    /** {@code hasRole('X')} 的调用形状;X 是角色名,**不是**权限串。 */
+    private static final Pattern ROLE_CALL =
+            Pattern.compile("@[A-Za-z_$][A-Za-z0-9_$]*\\s*\\.\\s*hasRole\\s*\\(\\s*'([^']*)'");
+
+    /** 只认 `INSERT INTO sys_menu`(大小写与空白宽松)——用于切「每个 sys_menu 插入块」。 */
+    private static final Pattern INSERT_SYS_MENU = Pattern.compile("(?i)\\bINSERT\\s+INTO\\s+sys_menu\\b");
+
+    private static final Pattern GEN_RANDOM_UUID = Pattern.compile("(?i)\\bgen_random_uuid\\s*\\(\\s*\\)");
+
+    /**
+     * 契约的**期望集合**:7 个权限串逐字取自 Task 16 的三个控制器
+     * (2026-10-20 于 BASE 28f4899b 现取):
+     * <ul>
+     *   <li>{@code UnifiedMerchantController#list}</li>
+     *   <li>{@code UnifiedOrderController#list / #detail / #sync / #refund}
+     *       ({@code #refunds} 复用 detail)</li>
+     *   <li>{@code UnifiedCashierController#create / #snapshot}</li>
+     * </ul>
+     * 商户 {@code save}/{@code delete} 是身份门禁,**不在**此集合里,且 SQL 不得建这两行。
+     */
+    private static final Set<String> EXPECTED_PERMISSIONS = Set.of(
+            "module_payment:unified:merchant:list",
+            "module_payment:unified:order:list",
+            "module_payment:unified:order:detail",
+            "module_payment:unified:order:sync",
+            "module_payment:unified:order:refund",
+            "module_payment:unified:cashier:create",
+            "module_payment:unified:cashier:list");
+
+    /** 后台控制器登记制:只有登记在这里的类才被本类守望(新增管理端控制器时须同时登记)。 */
+    private static final List<Class<?>> ADMIN_CONTROLLERS = List.of(
+            UnifiedMerchantController.class,
+            UnifiedOrderController.class,
+            UnifiedCashierController.class);
+
+    @Test
+    void menuSql_permissions_areExactlyTheSevenContractStrings() throws Exception {
+        Set<String> actual = permissionsInSql();
+        assertEquals(EXPECTED_PERMISSIONS, actual,
+                "db/unified-menu.sql 的 permission 去重集合与契约不符。授权/只读核对段重复引用是允许的,"
+                        + "但**集合**必须正好这 7 个 —— 多一个意味着多授了一个后端不认的串,"
+                        + "少一个意味着对应端点非超管必 403。读数: " + actual);
+
+        // ★ 附加护栏(简报到集合断言为止,这一段补的是集合断言的盲区):
+        //   `grant` 段会重复引用按钮权限串 ⇒ 把某个按钮行删掉、授权段仍留着该串时,
+        //   集合仍是 7 个、上面的断言照样绿(变异实测如此)。
+        //   这里要求每个契约串都真的落在某段 `INSERT INTO sys_menu` 语句内
+        //   (块以语句分号界定,授权段不在任何 sys_menu 块里),把「建行」与「引用」分开。
+        Set<String> inserted = new LinkedHashSet<>();
+        for (String block : insertSysMenuBlocks(strippedMenuSql())) {
+            Matcher m = PERMISSION.matcher(block);
+            while (m.find()) {
+                inserted.add(m.group(1));
+            }
+        }
+        Set<String> notInserted = new LinkedHashSet<>(EXPECTED_PERMISSIONS);
+        notInserted.removeAll(inserted);
+        assertTrue(notInserted.isEmpty(),
+                "这些权限串只在授权/核对段被引用,没有任何 sys_menu 插入行定义它们 ⇒ "
+                        + "菜单树里没有对应行,授权也授不到实体行、按钮点开必 403: " + notInserted);
+    }
+
+    @Test
+    void controllerAnnotations_areExactlyTheSevenContractStrings() throws Exception {
+        Set<String> actual = permissionsInControllers();
+        assertEquals(EXPECTED_PERMISSIONS, actual,
+                "三个管理端控制器的 @PreAuthorize 权限串集合与契约不符。"
+                        + "商户 save/delete 必须走 hasRole('SUPERUSER'),不得混入权限串。读数: " + actual);
+    }
+
+    @Test
+    void sqlAndAnnotations_agreeInBothDirections() throws Exception {
+        Set<String> sql = permissionsInSql();
+        Set<String> annotations = permissionsInControllers();
+        assertFalse(annotations.isEmpty(), "注解侧一个权限串都没取到 —— 取数式失效时双向断言会在空集上平凡通过");
+
+        Set<String> onlyInAnnotations = new LinkedHashSet<>(annotations);
+        onlyInAnnotations.removeAll(sql);
+        assertTrue(onlyInAnnotations.isEmpty(),
+                "控制器要求了 SQL 里不存在的权限串 ⇒ 该权限永远授不出来(非超管一律 403): " + onlyInAnnotations);
+
+        Set<String> onlyInSql = new LinkedHashSet<>(sql);
+        onlyInSql.removeAll(annotations);
+        assertTrue(onlyInSql.isEmpty(),
+                "SQL 塞了控制器不认的权限串 ⇒ 菜单授了、后端不认,按钮点了必 403: " + onlyInSql);
+    }
+
+    @Test
+    void merchantWriteEndpoints_areGatedBySuperuserIdentity_andHaveNoMenuRows() throws Exception {
+        for (String name : List.of("save", "delete")) {
+            Method m = Arrays.stream(UnifiedMerchantController.class.getDeclaredMethods())
+                    .filter(x -> x.getName().equals(name))
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError(
+                            "UnifiedMerchantController 里找不到 " + name + " —— 端点被删或改名后,"
+                                    + "本用例会静默地什么都不守,故这里响亮失败"));
+            PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
+            assertNotNull(pa, name + " 缺 @PreAuthorize —— 端点会同时失去认证与租户条件");
+
+            Set<String> roles = new LinkedHashSet<>();
+            for (String r : roleLiterals(pa.value())) {
+                roles.add(normalizeRole(r));
+            }
+            assertEquals(Set.of("ROLE_SUPERUSER"), roles,
+                    name + " 的角色门禁不是超管(或压根没有身份门禁)。实测: " + pa.value());
+
+            Set<String> perms = permissionLiterals(pa.value());
+            assertTrue(perms.isEmpty(), name + " 上还挂着权限串 " + perms
+                    + " —— 商户写入口是身份门禁,不是权限串。实测: " + pa.value());
+        }
+
+        String sql = strippedMenuSql();
+        assertFalse(sql.contains("merchant:save"),
+                "SQL 里出现了 merchant:save —— save 是超管身份门禁,不该有可授予的权限行");
+        assertFalse(sql.contains("merchant:delete"),
+                "SQL 里出现了 merchant:delete —— delete 是超管身份门禁,不该有可授予的权限行");
+    }
+
+    @Test
+    void everySysMenuInsert_setsUuidExplicitly() throws Exception {
+        String code = strippedMenuSql();
+        List<String> blocks = insertSysMenuBlocks(code);
+        assertTrue(blocks.size() >= 3,
+                "至少应有目录/页面/按钮三段 INSERT INTO sys_menu,实测 " + blocks.size() + " 段。"
+                        + "段数不足时下面的逐块断言会漏测");
+
+        int total = 0;
+        for (int i = 0; i < blocks.size(); i++) {
+            String block = blocks.get(i);
+            Matcher m = GEN_RANDOM_UUID.matcher(block);
+            int count = 0;
+            while (m.find()) {
+                count++;
+            }
+            total += count;
+            assertTrue(count > 0,
+                    "第 " + (i + 1) + " 段 INSERT INTO sys_menu 没有显式给 uuid 赋 gen_random_uuid() ⇒ "
+                            + "uuid 为 NULL 时前端可能拿不到稳定标识。该段开头: " + head(block));
+        }
+        assertTrue(total >= 3, "gen_random_uuid() 总出现次数应 ≥ 3(三段各至少一次),实测 " + total);
+    }
+
+    @Test
+    void grantStatement_includesBothRole1AndRole2() throws Exception {
+        String code = strippedMenuSql();
+        Matcher m = Pattern.compile("(?i)\\bINSERT\\s+INTO\\s+sys_role_menus\\b").matcher(code);
+        assertTrue(m.find(), "SQL 里没有 INSERT INTO sys_role_menus —— 菜单建了但没授权,等于没建");
+        int start = m.start();
+        int semi = code.indexOf(';', start);
+        String statement = semi < 0 ? code.substring(start) : code.substring(start, semi);
+        String normalized = statement.replaceAll("\\s+", "").toLowerCase();
+
+        assertTrue(normalized.contains("values(1)"),
+                "授权段没有 role 1(管理员角色): " + head(statement));
+        // 两种合法形状:①一次插完 `(VALUES (1), (2))`;②两段语句各写 `VALUES (1)` / `VALUES (2)`。
+        // ★ 去空白后 `(VALUES (1), (2))` 是 `(values(1),(2))` —— `,2)` 这种串**不存在**
+        //   (逗号后面是左括号),别把它当宽松匹配写进来(初版就是这么误红的)。
+        boolean bothInOneCrossJoin = normalized.contains("(values(1),(2))");
+        boolean separateRole2Values = normalized.contains("values(2)");
+        assertTrue(bothInOneCrossJoin || separateRole2Values,
+                "授权段没有 role 2(租户角色)—— 本任务要求目录/三页/四按钮都授给 role 1 与 role 2。"
+                        + "实测授权语句: " + head(statement));
+    }
+
+    @Test
+    void adminControllerMappings_neverLiveOnTheOpenApiSurface() throws Exception {
+        for (Class<?> type : ADMIN_CONTROLLERS) {
+            RequestMapping rm = AnnotatedElementUtils.findMergedAnnotation(type, RequestMapping.class);
+            assertNotNull(rm, type.getSimpleName() + " 缺类级 @RequestMapping");
+            assertTrue(rm.path().length > 0, type.getSimpleName() + " 的 @RequestMapping 未给出路径");
+            for (String base : rm.path()) {
+                assertFalse(base.isBlank(), type.getSimpleName() + " 的类级映射是空串");
+                assertFalse(base.startsWith("/payment/openapi"),
+                        type.getSimpleName() + " 的后台端点落到了开放 API 前缀下(" + base + ")—— "
+                                + "后台只走 JWT,混进 ApiKey 认证面会被认证成 ROLE_OPENAPI 再全等匹配成 403");
+            }
+        }
+    }
+
+    // ────────────────────────────── 取数工具 ──────────────────────────────
+
+    private static Set<String> permissionsInSql() throws Exception {
+        Set<String> out = new LinkedHashSet<>();
+        Matcher m = PERMISSION.matcher(strippedMenuSql());
+        while (m.find()) {
+            out.add(m.group(1));
+        }
+        return out;
+    }
+
+    /** 三个控制器上**全部** {@code @PreAuthorize} 里的权限串(去掉 hasRole 的角色名)。 */
+    private static Set<String> permissionsInControllers() {
+        Set<String> out = new LinkedHashSet<>();
+        for (Class<?> type : ADMIN_CONTROLLERS) {
+            for (Method m : type.getDeclaredMethods()) {
+                PreAuthorize pa = m.getAnnotation(PreAuthorize.class);
+                if (pa != null) {
+                    out.addAll(permissionLiterals(pa.value()));
+                }
+            }
+        }
+        return out;
+    }
+
+    /** 从 classpath 读菜单脚本并剥行注释(`--` 到行尾)。 */
+    private static String strippedMenuSql() throws Exception {
+        try (InputStream in = UnifiedAdminWiringTest.class.getClassLoader().getResourceAsStream(MENU_SQL)) {
+            assertNotNull(in, "找不到菜单脚本 " + MENU_SQL + "(应位于 src/main/resources 下)");
+            return new String(in.readAllBytes(), StandardCharsets.UTF_8)
+                    .replaceAll("(?m)--[^\\n]*", "");
+        }
+    }
+
+    /**
+     * 按 `INSERT INTO sys_menu` 切块:每块从该 INSERT 起、到**本语句的分号**为止
+     * (或下一个 INSERT / 文末,取先到者)。★ 不能用「到下一个 INSERT」—— 最后一段会一路吞到
+     * 文末的授权段,于是「权限串只在授权段被引用」也会被算进 sys_menu 块,断言就失效了。
+     */
+    private static List<String> insertSysMenuBlocks(String code) {
+        List<String> blocks = new ArrayList<>();
+        Matcher m = INSERT_SYS_MENU.matcher(code);
+        List<Integer> starts = new ArrayList<>();
+        while (m.find()) {
+            starts.add(m.start());
+        }
+        for (int i = 0; i < starts.size(); i++) {
+            int nextInsert = i + 1 < starts.size() ? starts.get(i + 1) : code.length();
+            int semi = code.indexOf(';', starts.get(i));
+            int end = semi >= 0 && semi < nextInsert ? semi + 1 : nextInsert;
+            blocks.add(code.substring(starts.get(i), end));
+        }
+        return blocks;
+    }
+
+    private static Set<String> quotedLiterals(String spel) {
+        Set<String> out = new LinkedHashSet<>();
+        Matcher m = Pattern.compile("'([^']*)'").matcher(spel);
+        while (m.find()) {
+            out.add(m.group(1));
+        }
+        return out;
+    }
+
+    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;
+    }
+
+    /** 权限串 = 引号字面量减去 hasRole 的参数(依据实际调用形状,不是字面量形状)。 */
+    private static Set<String> permissionLiterals(String spel) {
+        Set<String> out = quotedLiterals(spel);
+        out.removeAll(roleLiterals(spel));
+        return out;
+    }
+
+    private static String normalizeRole(String role) {
+        return role.startsWith("ROLE_") ? role : "ROLE_" + role;
+    }
+
+    private static String head(String text) {
+        String oneLine = text.replaceAll("\\s+", " ").trim();
+        return oneLine.length() <= 220 ? oneLine : oneLine.substring(0, 220) + "…";
+    }
+}