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

fix(backend): SortUtil 加固 - 列名白名单防 SQL 注入 + clearOrderBy 清除已有 ORDER BY 防双排序语法错误

feat(backend): WebSortConfig 扩展拦截器覆盖 /api/**

fix(backend): ReportParser 3 个列表端点(types/imports/unknown-clusters)接入 sort 排序参数

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 1 месяц назад
Родитель
Сommit
9adfd76b6d

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/WebSortConfig.java

@@ -12,8 +12,9 @@ public class WebSortConfig implements WebMvcConfigurer {
 
 
     @Override
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
     public void addInterceptors(InterceptorRegistry registry) {
+        // 覆盖所有 /api/** 路径:SortInterceptor 只读取 sort 参数注入 ThreadLocal,
+        // 无 sort 参数时为无操作,不影响其他接口行为
         registry.addInterceptor(sortInterceptor)
         registry.addInterceptor(sortInterceptor)
-                .addPathPatterns("/api/admin/**")
-                .addPathPatterns("/api/health/**");
+                .addPathPatterns("/api/**");
     }
     }
 }
 }

+ 27 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -59,8 +59,15 @@ public class ReportParserAdminController {
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 999;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 999;
         Page<ReportTypeRegistry> pageParam = new Page<>(page, size);
         Page<ReportTypeRegistry> pageParam = new Page<>(page, size);
-        LambdaQueryWrapper<ReportTypeRegistry> wrapper = new LambdaQueryWrapper<ReportTypeRegistry>()
-                .orderByDesc(ReportTypeRegistry::getId);
+        LambdaQueryWrapper<ReportTypeRegistry> wrapper = new LambdaQueryWrapper<>();
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        if (sortSpecs == null || sortSpecs.isEmpty()) {
+            wrapper.orderByDesc(ReportTypeRegistry::getId);
+        }
+        SortUtil.applySort(wrapper, sortSpecs, null);
         return Result.success(typeRegistryMapper.selectPage(pageParam, wrapper));
         return Result.success(typeRegistryMapper.selectPage(pageParam, wrapper));
     }
     }
 
 
@@ -160,8 +167,15 @@ public class ReportParserAdminController {
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
         Page<ReportParserImport> pageParam = new Page<>(page, size);
         Page<ReportParserImport> pageParam = new Page<>(page, size);
-        LambdaQueryWrapper<ReportParserImport> wrapper = new LambdaQueryWrapper<ReportParserImport>()
-                .orderByDesc(ReportParserImport::getCreatedAt);
+        LambdaQueryWrapper<ReportParserImport> wrapper = new LambdaQueryWrapper<>();
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        if (sortSpecs == null || sortSpecs.isEmpty()) {
+            wrapper.orderByDesc(ReportParserImport::getCreatedAt);
+        }
+        SortUtil.applySort(wrapper, sortSpecs, null);
         return Result.success(importMapper.selectPage(pageParam, wrapper));
         return Result.success(importMapper.selectPage(pageParam, wrapper));
     }
     }
 
 
@@ -199,11 +213,18 @@ public class ReportParserAdminController {
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
         int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
         String status = (String) params.get("status");
         String status = (String) params.get("status");
         Page<ReportUnknownCluster> pageParam = new Page<>(page, size);
         Page<ReportUnknownCluster> pageParam = new Page<>(page, size);
-        LambdaQueryWrapper<ReportUnknownCluster> wrapper = new LambdaQueryWrapper<ReportUnknownCluster>()
-                .orderByDesc(ReportUnknownCluster::getReportCount);
+        LambdaQueryWrapper<ReportUnknownCluster> wrapper = new LambdaQueryWrapper<>();
         if (status != null && !status.isEmpty()) {
         if (status != null && !status.isEmpty()) {
             wrapper.eq(ReportUnknownCluster::getStatus, status);
             wrapper.eq(ReportUnknownCluster::getStatus, status);
         }
         }
+        @SuppressWarnings("unchecked")
+        List<Map<String, String>> sortSpecs = params.get("sort") != null
+                ? (List<Map<String, String>>) params.get("sort")
+                : null;
+        if (sortSpecs == null || sortSpecs.isEmpty()) {
+            wrapper.orderByDesc(ReportUnknownCluster::getReportCount);
+        }
+        SortUtil.applySort(wrapper, sortSpecs, null);
         return Result.success(unknownClusterMapper.selectPage(pageParam, wrapper));
         return Result.success(unknownClusterMapper.selectPage(pageParam, wrapper));
     }
     }
 
 

+ 75 - 17
cfc-backend/src/main/java/com/etotem/cfc/util/SortUtil.java

@@ -1,24 +1,83 @@
 package com.etotem.cfc.util;
 package com.etotem.cfc.util;
 
 
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
+import com.baomidou.mybatisplus.core.conditions.segments.MergeSegments;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.util.StringUtils;
 import org.springframework.util.StringUtils;
 
 
+import java.lang.reflect.Field;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.regex.Pattern;
 
 
 /**
 /**
  * 通用排序工具:将前端 sort 参数注入 MyBatis-Plus 查询包装器。
  * 通用排序工具:将前端 sort 参数注入 MyBatis-Plus 查询包装器。
  * 支持 LambdaQueryWrapper、QueryWrapper 和 LambdaUpdateWrapper。
  * 支持 LambdaQueryWrapper、QueryWrapper 和 LambdaUpdateWrapper。
+ *
+ * 排序参数格式(前端 SortMixin 产出):
+ *   [{column: 'id', order: 'asc'}, {column: 'name', order: 'desc'}]
+ *
+ * 列名映射:驼峰属性名 → 下划线列名(如 displayName → display_name)。
+ * 可通过 snakeMap 提供自定义映射。
+ *
+ * 安全:列名仅限 [a-zA-Z_][a-zA-Z0-9_]*,防止 SQL 注入。
+ *
+ * 双 ORDER BY 防护:有用户排序时,通过反射清除 wrapper 内已有的 orderBy 段并失效缓存,
+ * 避免与 lambda 排序(如 wrapper.orderByDesc(...))产生双 ORDER BY 语法错误。
  */
  */
 public class SortUtil {
 public class SortUtil {
 
 
+    private static final Logger log = LoggerFactory.getLogger(SortUtil.class);
+    private static final Pattern SAFE_COLUMN = Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$");
+
+    /** 反射缓存:MergeSegments 的 cacheSqlSegment 字段 */
+    private static final Field CACHE_SQL_SEGMENT_FIELD;
+    private static final Field EXPRESSION_FIELD;
+
+    static {
+        Field cacheField = null;
+        Field exprField = null;
+        try {
+            Class<?> mergeClass = Class.forName(
+                    "com.baomidou.mybatisplus.core.conditions.segments.MergeSegments");
+            cacheField = mergeClass.getDeclaredField("cacheSqlSegment");
+            cacheField.setAccessible(true);
+
+            Class<?> wrapperClass = Class.forName(
+                    "com.baomidou.mybatisplus.core.conditions.AbstractWrapper");
+            exprField = wrapperClass.getDeclaredField("expression");
+            exprField.setAccessible(true);
+        } catch (Exception e) {
+            log.error("[SortUtil] 反射初始化失败,将回退到旧行为", e);
+        }
+        CACHE_SQL_SEGMENT_FIELD = cacheField;
+        EXPRESSION_FIELD = exprField;
+    }
+
+    /**
+     * 清除 wrapper 中已有的 ORDER BY 段,并使其缓存失效。
+     * 确保后续追加的 ORDER BY 不会与原有 lambda orderBy 冲突(双 ORDER BY 语法错误)。
+     */
+    static void clearOrderBy(AbstractWrapper<?, ?, ?> wrapper) {
+        try {
+            MergeSegments expression = (MergeSegments) EXPRESSION_FIELD.get(wrapper);
+            if (expression != null) {
+                expression.getOrderBy().clear();
+                if (CACHE_SQL_SEGMENT_FIELD != null) {
+                    CACHE_SQL_SEGMENT_FIELD.setBoolean(expression, false);
+                }
+            }
+        } catch (Exception e) {
+            log.warn("[SortUtil] 清除已有 ORDER BY 失败: {}", e.getMessage());
+        }
+    }
+
     /**
     /**
      * 对 QueryWrapper 应用排序(从 SortContext 读取排序条件)。
      * 对 QueryWrapper 应用排序(从 SortContext 读取排序条件)。
-     * 无排序上下文时为空操作(保留原有排序)。
-     *
-     * @param wrapper QueryWrapper 实例
      */
      */
     public static <T> void applySort(QueryWrapper<T> wrapper) {
     public static <T> void applySort(QueryWrapper<T> wrapper) {
         applySort(wrapper, SortContext.get(), null);
         applySort(wrapper, SortContext.get(), null);
@@ -26,9 +85,6 @@ public class SortUtil {
 
 
     /**
     /**
      * 对 LambdaQueryWrapper 应用排序(从 SortContext 读取排序条件)。
      * 对 LambdaQueryWrapper 应用排序(从 SortContext 读取排序条件)。
-     * 无排序上下文时为空操作(保留原有排序)。
-     *
-     * @param wrapper LambdaQueryWrapper 实例
      */
      */
     public static <T> void applySort(LambdaQueryWrapper<T> wrapper) {
     public static <T> void applySort(LambdaQueryWrapper<T> wrapper) {
         applySort(wrapper, SortContext.get(), null);
         applySort(wrapper, SortContext.get(), null);
@@ -36,9 +92,6 @@ public class SortUtil {
 
 
     /**
     /**
      * 对 LambdaUpdateWrapper 应用排序(从 SortContext 读取排序条件)。
      * 对 LambdaUpdateWrapper 应用排序(从 SortContext 读取排序条件)。
-     * 无排序上下文时为空操作(保留原有排序)。
-     *
-     * @param wrapper LambdaUpdateWrapper 实例
      */
      */
     public static <T> void applySort(LambdaUpdateWrapper<T> wrapper) {
     public static <T> void applySort(LambdaUpdateWrapper<T> wrapper) {
         applySort(wrapper, SortContext.get(), null);
         applySort(wrapper, SortContext.get(), null);
@@ -46,10 +99,6 @@ public class SortUtil {
 
 
     /**
     /**
      * 对 QueryWrapper 应用排序。如果 sortSpecs 为空则不修改排序(保留原有排序)。
      * 对 QueryWrapper 应用排序。如果 sortSpecs 为空则不修改排序(保留原有排序)。
-     *
-     * @param wrapper    QueryWrapper 实例
-     * @param sortSpecs  前端传入的排序数组,每项 {column, order}
-     * @param snakeMap   驼峰属性名 → 数据库列名 映射(可为 null)
      */
      */
     @SuppressWarnings("unchecked")
     @SuppressWarnings("unchecked")
     public static <T> void applySort(QueryWrapper<T> wrapper,
     public static <T> void applySort(QueryWrapper<T> wrapper,
@@ -67,14 +116,13 @@ public class SortUtil {
             sb.append(dbCol).append(asc ? " asc" : " desc");
             sb.append(dbCol).append(asc ? " asc" : " desc");
         }
         }
         if (sb.length() > 11) {
         if (sb.length() > 11) {
+            clearOrderBy(wrapper);
             wrapper.last(sb.toString());
             wrapper.last(sb.toString());
         }
         }
     }
     }
 
 
     /**
     /**
      * 对 LambdaQueryWrapper 应用排序(通过 last SQL 追加)。
      * 对 LambdaQueryWrapper 应用排序(通过 last SQL 追加)。
-     * 因为 LambdaQueryWrapper 没有通用的 orderBy 重载接受 db 列名字符串,
-     * 所以直接通过 last() 追加。
      */
      */
     public static <T> void applySort(LambdaQueryWrapper<T> wrapper,
     public static <T> void applySort(LambdaQueryWrapper<T> wrapper,
                                      List<Map<String, String>> sortSpecs,
                                      List<Map<String, String>> sortSpecs,
@@ -91,6 +139,7 @@ public class SortUtil {
             sb.append(dbCol).append(asc ? " asc" : " desc");
             sb.append(dbCol).append(asc ? " asc" : " desc");
         }
         }
         if (sb.length() > 11) {
         if (sb.length() > 11) {
+            clearOrderBy(wrapper);
             wrapper.last(sb.toString());
             wrapper.last(sb.toString());
         }
         }
     }
     }
@@ -113,15 +162,24 @@ public class SortUtil {
             sb.append(dbCol).append(asc ? " asc" : " desc");
             sb.append(dbCol).append(asc ? " asc" : " desc");
         }
         }
         if (sb.length() > 11) {
         if (sb.length() > 11) {
+            clearOrderBy(wrapper);
             wrapper.last(sb.toString());
             wrapper.last(sb.toString());
         }
         }
     }
     }
 
 
     private static String resolveDbColumn(String camelCol, Map<String, String> snakeMap) {
     private static String resolveDbColumn(String camelCol, Map<String, String> snakeMap) {
         if (snakeMap != null && snakeMap.containsKey(camelCol)) {
         if (snakeMap != null && snakeMap.containsKey(camelCol)) {
-            return snakeMap.get(camelCol);
+            String mapped = snakeMap.get(camelCol);
+            if (!SAFE_COLUMN.matcher(mapped).matches()) {
+                throw new IllegalArgumentException("非法的排序列名: " + mapped);
+            }
+            return mapped;
         }
         }
         // 默认:驼峰转下划线
         // 默认:驼峰转下划线
-        return camelCol.replaceAll("([A-Z])", "_$1").toLowerCase();
+        String dbCol = camelCol.replaceAll("([A-Z])", "_$1").toLowerCase();
+        if (!SAFE_COLUMN.matcher(dbCol).matches()) {
+            throw new IllegalArgumentException("非法的排序列名: " + camelCol);
+        }
+        return dbCol;
     }
     }
 }
 }