Ver Fonte

fix(系统): 角色授权保存 500 —— 批量插入的内层 foreach 少了逗号

RoleMapper 两条批量插入注解里,外层 foreach 有 separator=',',内层没有:

  VALUES <foreach roleIds separator=','><foreach menuIds>(...)</foreach></foreach>

于是一个角色配 N 个菜单生成的是 `VALUES (?, ?) (?, ?) ...` —— 元组之间没有逗号。
MyBatis-Plus 的租户行拦截器拿 JSqlParser 解这条 SQL 时抛
`MybatisPlusException: Failed to process, Error SQL: INSERT INTO sys_role_menus ...`,
/system/role/permission/setting 整个 500。线上 2026-09-28 11:48 实测(一个角色 28 个菜单)。

只配**一个**菜单时 SQL 恰好合法(单元素组不需要逗号),所以这条缺陷从 00e98dc(2026-05-30
Python→Java 平移)原样躺到今天;而权限抽屉此前读不到已授权的菜单(1bdaa59 才修好),
也没人能真的点到保存。两条腿今天才凑齐 —— 缺陷何时进来的由
`git log -S "<foreach collection='menuIds' item='menuId'>"` 定死:只有 00e98dc 加、本提交删。

RoleMapperSqlTest 用 MyBatis 自己的 XMLLanguageDriver 求值注解里的 script,再把结果交给
CCJSqlParserUtil —— 与 MyBatis-Plus 租户拦截器同一支解析器。RoleServiceTest 把 RoleMapper
整个 mock 掉,「注解里的 SQL 长什么样」这类缺陷在它眼里完全不可见。

两向读数:
- 修前 3/3 红,报文就是线上那条 `VALUES (?, ?) (?, ?) (?, ?)`;两角色那条臂显示块之间的
  逗号**在**,缺的只有内层 ⇒ 根因定位到内层 foreach,不是外层
- 单量探针:JSqlParser 对缺逗号那条抛 `Encountered unexpected token: "("`,对有逗号那条
  PARSED-OK(正对照,证明这支尺不是在乱拒)
- 修后全量 893 绿(此前 890)

insertRoleDepts 是同一处缺陷(data_scope=5 且多部门时同样 500),一并修。
alphaH há 2 semanas atrás
pai
commit
06c70a78b1

+ 2 - 2
java/src/main/java/com/payment/platform/module/system/role/mapper/RoleMapper.java

@@ -53,7 +53,7 @@ public interface RoleMapper extends BaseMapper<RoleEntity> {
     void deleteRoleMenusByRoleIds(List<Long> roleIds);
 
     /** 批量插入角色菜单关联 */
-    @Insert("<script>INSERT INTO sys_role_menus (role_id, menu_id) VALUES <foreach collection='roleIds' item='roleId' separator=','><foreach collection='menuIds' item='menuId'>(#{roleId}, #{menuId})</foreach></foreach></script>")
+    @Insert("<script>INSERT INTO sys_role_menus (role_id, menu_id) VALUES <foreach collection='roleIds' item='roleId' separator=','><foreach collection='menuIds' item='menuId' separator=','>(#{roleId}, #{menuId})</foreach></foreach></script>")
     void insertRoleMenus(List<Long> roleIds, List<Long> menuIds);
 
     /** 删除指定角色的部门关联 */
@@ -61,6 +61,6 @@ public interface RoleMapper extends BaseMapper<RoleEntity> {
     void deleteRoleDeptsByRoleIds(List<Long> roleIds);
 
     /** 批量插入角色部门关联 */
-    @Insert("<script>INSERT INTO sys_role_depts (role_id, dept_id) VALUES <foreach collection='roleIds' item='roleId' separator=','><foreach collection='deptIds' item='deptId'>(#{roleId}, #{deptId})</foreach></foreach></script>")
+    @Insert("<script>INSERT INTO sys_role_depts (role_id, dept_id) VALUES <foreach collection='roleIds' item='roleId' separator=','><foreach collection='deptIds' item='deptId' separator=','>(#{roleId}, #{deptId})</foreach></foreach></script>")
     void insertRoleDepts(List<Long> roleIds, List<Long> deptIds);
 }

+ 82 - 0
java/src/test/java/com/payment/platform/module/system/role/mapper/RoleMapperSqlTest.java

@@ -0,0 +1,82 @@
+package com.payment.platform.module.system.role.mapper;
+
+import net.sf.jsqlparser.parser.CCJSqlParserUtil;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.mapping.SqlSource;
+import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
+import org.apache.ibatis.session.Configuration;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 钉住 RoleMapper 里两条**批量插入**注解生成出来的 SQL 形状。
+ *
+ * <p>【为什么必须单独钉】{@code RoleServiceTest} 把 RoleMapper 整个 mock 掉 ⇒
+ * 这些注解里的 {@code <foreach>} 一次都没被 MyBatis 真正求值过,生成的 SQL 长什么样
+ * 在测试里完全不可见。线上实测(2026-09-28 11:48,{@code /system/role/permission/setting}):
+ * 内层 {@code <foreach>} 少了 {@code separator=','},一个角色配 28 个菜单生成的是
+ * {@code INSERT INTO sys_role_menus (role_id, menu_id) VALUES (?, ?) (?, ?) ...}
+ * —— 元组之间没有逗号 —— MyBatis-Plus 的租户行拦截器拿 JSQLParser 解这条 SQL 时抛
+ * {@code MybatisPlusException: Failed to process, Error SQL: ...},整个保存 500。
+ *
+ * <p>【量具一】不手抄注解字符串:走 MyBatis 自己的注解动态 SQL 解析
+ * ({@link XMLLanguageDriver}),求值出来的就是线上那条 BoundSql。
+ * 归一化只做两件事:空白折叠、逗号两侧留一个空格 —— 逗号**有没有**必须由断言管,
+ * 逗号前的空格是 MyBatis foreach 拼串的产物,不是被测对象。
+ *
+ * <p>【量具二】再拿 {@link CCJSqlParserUtil} 解一遍。这不是我自制的规则:MyBatis-Plus 的
+ * {@code JsqlParserSupport} 走的就是这支解析器,线上那个异常的出处正是它。
+ */
+class RoleMapperSqlTest {
+
+    /** 取注解里的 script,交给 MyBatis 真求值,返回空白归一的最终 SQL。 */
+    private static String boundSql(String method, Map<String, Object> params) {
+        Method m = Arrays.stream(RoleMapper.class.getDeclaredMethods())
+                .filter(x -> x.getName().equals(method))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("RoleMapper 里没有方法 " + method));
+        Insert insert = m.getAnnotation(Insert.class);
+        if (insert == null) throw new AssertionError(method + " 上不是 @Insert");
+        SqlSource sqlSource = new XMLLanguageDriver()
+                .createSqlSource(new Configuration(), insert.value()[0], Object.class);
+        return sqlSource.getBoundSql(params).getSql()
+                .replaceAll("\\s+", " ")
+                .replaceAll("\\s*,\\s*", ", ")
+                .trim();
+    }
+
+    private static void assertUsableMultiRowInsert(String expected, String sql) {
+        assertEquals(expected, sql);
+        assertDoesNotThrow(() -> CCJSqlParserUtil.parse(sql),
+                "MyBatis-Plus 租户拦截器解析不了这条 SQL(线上就是这里炸的): " + sql);
+    }
+
+    @Test
+    void insertRoleMenus_一个角色三个菜单_元组之间必须有逗号() {
+        assertUsableMultiRowInsert(
+                "INSERT INTO sys_role_menus (role_id, menu_id) VALUES (?, ?), (?, ?), (?, ?)",
+                boundSql("insertRoleMenus", Map.of("roleIds", List.of(1L), "menuIds", List.of(11L, 12L, 13L))));
+    }
+
+    @Test
+    void insertRoleDepts_一个角色两个部门_元组之间必须有逗号() {
+        assertUsableMultiRowInsert(
+                "INSERT INTO sys_role_depts (role_id, dept_id) VALUES (?, ?), (?, ?)",
+                boundSql("insertRoleDepts", Map.of("roleIds", List.of(1L), "deptIds", List.of(7L, 8L))));
+    }
+
+    /** 多个角色时,角色块之间由外层 foreach 的 separator 补逗号;这里钉住它没补成两个。 */
+    @Test
+    void insertRoleMenus_两个角色两个菜单_四个元组三个逗号() {
+        assertUsableMultiRowInsert(
+                "INSERT INTO sys_role_menus (role_id, menu_id) VALUES (?, ?), (?, ?), (?, ?), (?, ?)",
+                boundSql("insertRoleMenus", Map.of("roleIds", List.of(1L, 2L), "menuIds", List.of(11L, 12L))));
+    }
+}