MyBatis-Plus 实现精准、模糊、批量搜索

背景

分页查询中常见三类搜索:

搜索模式 SQL 语义
模糊搜索 LIKE '%value%'
精准搜索 = value
批量搜索 IN (v1, v2, v3)

如果每个字段都手写单值参数、批量参数、LIKEIN,Mapper 和 XML 很快会膨胀。本文实现一套通用搜索协议,同时支持:

  • MyBatis-Plus Wrapper 分页
  • XML 复杂 SQL 分页
  • EQ / LIKE / IN
  • BETWEEN / GT / GE / LT / LE

文档中的代码示例均使用泛化命名,不绑定具体项目包名和具体业务域。实际落地时,将类放入项目已有公共模块或业务模块即可。

一、统一请求协议

前端统一传 filters

json 复制代码
{
  "pageNo": 1,
  "pageSize": 10,
  "filters": [
    {
      "field": "recordNo",
      "op": "LIKE",
      "value": "R001"
    },
    {
      "field": "status",
      "op": "IN",
      "values": [1, 2, 3]
    },
    {
      "field": "amount",
      "op": "BETWEEN",
      "values": [10, 20]
    }
  ]
}

field 是前端逻辑字段名,通常对齐页面字段或响应 VO 字段;真正的数据库字段由后端 Mapper 白名单决定。

二、公共数据结构

QueryOp

java 复制代码
public enum QueryOp {
    EQ,
    LIKE,
    IN,
    BETWEEN,
    GT,
    GE,
    LT,
    LE
}

QueryFilter

java 复制代码
@Data
@Schema(description = "通用查询条件")
public class QueryFilter {

    @Schema(description = "前端逻辑字段名")
    private String field;

    @Schema(description = "查询操作")
    private QueryOp op;

    @Schema(description = "单值")
    private Object value;

    @Schema(description = "多值")
    private List<Object> values;

}

SearchPageParam

java 复制代码
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class SearchPageParam extends PageParam {

    /**
     * 前端传入的通用查询条件
     */
    private List<QueryFilter> filters;

    /**
     * XML 查询编译后的 SQL 条件。
     * 该字段只允许后端白名单编译生成,不能由前端直接控制。
     */
    @JsonIgnore
    private List<SqlFilterCondition> sqlFilters;

}

SqlFilterCondition

java 复制代码
@Data
public class SqlFilterCondition {

    /**
     * 后端白名单编译后的 SQL 字段,例如 t.record_no
     */
    private String column;

    private QueryOp op;

    private Object value;

    private List<Object> values;

}

三、MyBatis-Plus Wrapper 查询实现

SearchField

java 复制代码
@Getter
public class SearchField<T> {

    private final String field;
    private final SFunction<T, ?> column;
    private final QueryOp defaultOp;
    private final Set<QueryOp> allowedOps;
    private final Function<Object, Object> converter;

    public SearchField(String field, SFunction<T, ?> column, Function<Object, Object> converter,
                       QueryOp... allowedOps) {
        if (allowedOps == null || allowedOps.length == 0) {
            throw new IllegalArgumentException("allowedOps must not be empty");
        }
        this.field = field;
        this.column = column;
        this.defaultOp = allowedOps[0];
        this.allowedOps = Collections.unmodifiableSet(EnumSet.copyOf(Arrays.asList(allowedOps)));
        this.converter = converter != null ? converter : Function.identity();
    }

    public boolean isAllowed(QueryOp op) {
        return allowedOps.contains(op);
    }

    public Object convert(Object value) {
        return value == null ? null : converter.apply(value);
    }

}

SearchFieldMap

java 复制代码
public class SearchFieldMap<T> {

    private final Map<String, SearchField<T>> fields;

    private SearchFieldMap(Map<String, SearchField<T>> fields) {
        this.fields = fields;
    }

    public SearchField<T> get(String field) {
        return fields.get(field);
    }

    public static <T> Builder<T> builder() {
        return new Builder<>();
    }

    public static class Builder<T> {

        private final Map<String, SearchField<T>> fields = new HashMap<>();

        public Builder<T> add(String field, SFunction<T, ?> column, QueryOp... allowedOps) {
            return add(field, column, null, allowedOps);
        }

        public Builder<T> add(String field, SFunction<T, ?> column, Function<Object, Object> converter,
                              QueryOp... allowedOps) {
            fields.put(field, new SearchField<>(field, column, converter, allowedOps));
            return this;
        }

        public Builder<T> likeEqIn(String field, SFunction<T, ?> column) {
            return likeEqIn(field, column, null);
        }

        public Builder<T> likeEqIn(String field, SFunction<T, ?> column, Function<Object, Object> converter) {
            return add(field, column, converter, QueryOp.LIKE, QueryOp.EQ, QueryOp.IN);
        }

        public Builder<T> eqIn(String field, SFunction<T, ?> column) {
            return eqIn(field, column, null);
        }

        public Builder<T> eqIn(String field, SFunction<T, ?> column, Function<Object, Object> converter) {
            return add(field, column, converter, QueryOp.EQ, QueryOp.IN);
        }

        public Builder<T> range(String field, SFunction<T, ?> column) {
            return range(field, column, null);
        }

        public Builder<T> range(String field, SFunction<T, ?> column, Function<Object, Object> converter) {
            return add(field, column, converter, QueryOp.BETWEEN, QueryOp.EQ, QueryOp.GT, QueryOp.GE,
                    QueryOp.LT, QueryOp.LE);
        }

        public SearchFieldMap<T> build() {
            return new SearchFieldMap<>(fields);
        }

    }

}

QueryFilterApplier

java 复制代码
public class QueryFilterApplier {

    private static final int MAX_IN_SIZE = 500;

    private QueryFilterApplier() {
    }

    public static <T> void apply(LambdaQueryWrapperX<T> wrapper, List<QueryFilter> filters,
                                 SearchFieldMap<T> fieldMap) {
        if (CollectionUtils.isEmpty(filters)) {
            return;
        }
        for (QueryFilter filter : filters) {
            applyOne(wrapper, filter, fieldMap);
        }
    }

    private static <T> void applyOne(LambdaQueryWrapperX<T> wrapper, QueryFilter filter,
                                     SearchFieldMap<T> fieldMap) {
        if (filter == null || !StringUtils.hasText(filter.getField())) {
            return;
        }

        SearchField<T> field = fieldMap.get(filter.getField());
        if (field == null) {
            throw new ServiceException("不支持的查询字段:" + filter.getField());
        }

        QueryOp op = filter.getOp() != null ? filter.getOp() : field.getDefaultOp();
        if (!field.isAllowed(op)) {
            throw new ServiceException("查询字段不支持该操作:" + filter.getField() + " " + op);
        }

        if (op == QueryOp.IN) {
            List<Object> values = convertValues(field, getValues(filter));
            if (values.isEmpty()) {
                return;
            }
            if (values.size() > MAX_IN_SIZE) {
                throw new ServiceException("批量查询数量不能超过 " + MAX_IN_SIZE);
            }
            wrapper.inIfPresent(field.getColumn(), values);
        } else if (op == QueryOp.BETWEEN) {
            List<Object> values = convertValues(field, getValues(filter));
            if (values.size() < 2) {
                return;
            }
            wrapper.betweenIfPresent(field.getColumn(), values.toArray());
        } else {
            Object value = field.convert(filter.getValue());
            applySingleValue(wrapper, field, op, value);
        }
    }

    private static <T> void applySingleValue(LambdaQueryWrapperX<T> wrapper, SearchField<T> field,
                                             QueryOp op, Object value) {
        if (op == QueryOp.EQ) {
            wrapper.eqIfPresent(field.getColumn(), value);
        } else if (op == QueryOp.LIKE) {
            wrapper.likeIfPresent(field.getColumn(), value == null ? null : String.valueOf(value));
        } else if (op == QueryOp.GT) {
            wrapper.gtIfPresent(field.getColumn(), value);
        } else if (op == QueryOp.GE) {
            wrapper.geIfPresent(field.getColumn(), value);
        } else if (op == QueryOp.LT) {
            wrapper.ltIfPresent(field.getColumn(), value);
        } else if (op == QueryOp.LE) {
            wrapper.leIfPresent(field.getColumn(), value);
        }
    }

    private static List<Object> getValues(QueryFilter filter) {
        if (!CollectionUtils.isEmpty(filter.getValues())) {
            return filter.getValues();
        }

        Object value = filter.getValue();
        if (value instanceof Collection) {
            return new ArrayList<>((Collection<?>) value);
        }

        List<Object> values = new ArrayList<>();
        if (value != null) {
            values.add(value);
        }
        return values;
    }

    private static <T> List<Object> convertValues(SearchField<T> field, List<Object> values) {
        List<Object> result = new ArrayList<>();
        for (Object value : values) {
            Object converted = field.convert(value);
            if (converted != null) {
                result.add(converted);
            }
        }
        return result;
    }

}

四、XML 查询实现

SqlSearchField

java 复制代码
@Getter
public class SqlSearchField {

    private final String field;
    private final String column;
    private final QueryOp defaultOp;
    private final Set<QueryOp> allowedOps;
    private final Function<Object, Object> converter;

    public SqlSearchField(String field, String column, Function<Object, Object> converter,
                          QueryOp... allowedOps) {
        if (allowedOps == null || allowedOps.length == 0) {
            throw new IllegalArgumentException("allowedOps must not be empty");
        }
        this.field = field;
        this.column = column;
        this.defaultOp = allowedOps[0];
        this.allowedOps = Collections.unmodifiableSet(EnumSet.copyOf(Arrays.asList(allowedOps)));
        this.converter = converter != null ? converter : Function.identity();
    }

    public boolean isAllowed(QueryOp op) {
        return allowedOps.contains(op);
    }

    public Object convert(Object value) {
        return value == null ? null : converter.apply(value);
    }

}

SqlSearchFieldMap

java 复制代码
public class SqlSearchFieldMap {

    private final Map<String, SqlSearchField> fields;

    private SqlSearchFieldMap(Map<String, SqlSearchField> fields) {
        this.fields = fields;
    }

    public SqlSearchField get(String field) {
        return fields.get(field);
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {

        private final Map<String, SqlSearchField> fields = new HashMap<>();

        public Builder add(String field, String column, QueryOp... allowedOps) {
            return add(field, column, null, allowedOps);
        }

        public Builder add(String field, String column, Function<Object, Object> converter,
                           QueryOp... allowedOps) {
            fields.put(field, new SqlSearchField(field, column, converter, allowedOps));
            return this;
        }

        public Builder likeEqIn(String alias, String field) {
            return add(field, column(alias, field), QueryOp.LIKE, QueryOp.EQ, QueryOp.IN);
        }

        public Builder eqIn(String alias, String field) {
            return add(field, column(alias, field), QueryOp.EQ, QueryOp.IN);
        }

        public Builder range(String alias, String field) {
            return add(field, column(alias, field), QueryOp.BETWEEN, QueryOp.EQ, QueryOp.GT, QueryOp.GE,
                    QueryOp.LT, QueryOp.LE);
        }

        private String column(String alias, String field) {
            String column = StringUtils.toUnderScoreCase(field);
            return alias == null || alias.length() == 0 ? column : alias + "." + column;
        }

        public SqlSearchFieldMap build() {
            return new SqlSearchFieldMap(fields);
        }

    }

}

SqlFilterCompiler

java 复制代码
public class SqlFilterCompiler {

    private static final int MAX_IN_SIZE = 500;

    private SqlFilterCompiler() {
    }

    public static List<SqlFilterCondition> compile(List<QueryFilter> filters, SqlSearchFieldMap fieldMap) {
        List<SqlFilterCondition> conditions = new ArrayList<>();
        if (CollectionUtils.isEmpty(filters)) {
            return conditions;
        }

        for (QueryFilter filter : filters) {
            SqlFilterCondition condition = compileOne(filter, fieldMap);
            if (condition != null) {
                conditions.add(condition);
            }
        }
        return conditions;
    }

    private static SqlFilterCondition compileOne(QueryFilter filter, SqlSearchFieldMap fieldMap) {
        if (filter == null || !StringUtils.hasText(filter.getField())) {
            return null;
        }

        SqlSearchField field = fieldMap.get(filter.getField());
        if (field == null) {
            throw new ServiceException("不支持的查询字段:" + filter.getField());
        }

        QueryOp op = filter.getOp() != null ? filter.getOp() : field.getDefaultOp();
        if (!field.isAllowed(op)) {
            throw new ServiceException("查询字段不支持该操作:" + filter.getField() + " " + op);
        }

        SqlFilterCondition condition = new SqlFilterCondition();
        condition.setColumn(field.getColumn());
        condition.setOp(op);

        if (op == QueryOp.IN || op == QueryOp.BETWEEN) {
            List<Object> values = convertValues(field, getValues(filter));
            if (values.isEmpty()) {
                return null;
            }
            if (op == QueryOp.IN && values.size() > MAX_IN_SIZE) {
                throw new ServiceException("批量查询数量不能超过 " + MAX_IN_SIZE);
            }
            if (op == QueryOp.BETWEEN && values.size() < 2) {
                return null;
            }
            condition.setValues(values);
        } else {
            Object value = field.convert(filter.getValue());
            if (value == null) {
                return null;
            }
            condition.setValue(value);
        }
        return condition;
    }

    private static List<Object> getValues(QueryFilter filter) {
        if (!CollectionUtils.isEmpty(filter.getValues())) {
            return filter.getValues();
        }
        Object value = filter.getValue();
        if (value instanceof Collection) {
            return new ArrayList<>((Collection<?>) value);
        }
        List<Object> values = new ArrayList<>();
        if (value != null) {
            values.add(value);
        }
        return values;
    }

    private static List<Object> convertValues(SqlSearchField field, List<Object> values) {
        List<Object> result = new ArrayList<>();
        for (Object value : values) {
            Object converted = field.convert(value);
            if (converted != null) {
                result.add(converted);
            }
        }
        return result;
    }

}

五、BaseMapperX 两种 Page 方法

Wrapper Page

java 复制代码
default PageResult<T> selectPageByFilters(SearchPageParam pageParam,
                                          LambdaQueryWrapperX<T> queryWrapper,
                                          SearchFieldMap<T> fieldMap) {
    QueryFilterApplier.apply(queryWrapper, pageParam.getFilters(), fieldMap);
    return selectPage(pageParam, queryWrapper);
}

XML Page

java 复制代码
default <P extends SearchPageParam, R> PageResult<R> selectPageBySqlFilters(
        P pageReqVO,
        SqlSearchFieldMap fieldMap,
        BiPageQueryFunction<R, P> queryFunction) {
    pageReqVO.setSqlFilters(SqlFilterCompiler.compile(pageReqVO.getFilters(), fieldMap));
    return selectPage(pageReqVO, queryFunction);
}

BaseMapperX 相关代码

java 复制代码
public interface BaseMapperX<T> extends MPJBaseMapper<T> {

    default PageResult<T> selectPage(PageParam pageParam, Wrapper<T> queryWrapper) {
        Page<T> mpPage = MyBatisUtils.buildPage(pageParam);
        selectPage(mpPage, queryWrapper);
        return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
    }

    default PageResult<T> selectPageByFilters(SearchPageParam pageParam,
                                              LambdaQueryWrapperX<T> queryWrapper,
                                              SearchFieldMap<T> fieldMap) {
        QueryFilterApplier.apply(queryWrapper, pageParam.getFilters(), fieldMap);
        return selectPage(pageParam, queryWrapper);
    }

    @FunctionalInterface
    interface BiPageQueryFunction<T, P extends PageParam> {
        IPage<T> apply(IPage<T> page, P param);
    }

    default <P extends PageParam, R> PageResult<R> selectPage(
            P pageReqVO,
            BiPageQueryFunction<R, P> queryFunction) {
        IPage<R> mpPage = new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize());
        List<OrderItem> orderItems = buildOrderItem(pageReqVO);
        if (!CollectionUtils.isEmpty(orderItems)) {
            mpPage.orders().addAll(orderItems);
        }
        IPage<R> pageResult = queryFunction.apply(mpPage, pageReqVO);
        return new PageResult<>(pageResult.getRecords(), pageResult.getTotal());
    }

    default <P extends SearchPageParam, R> PageResult<R> selectPageBySqlFilters(
            P pageReqVO,
            SqlSearchFieldMap fieldMap,
            BiPageQueryFunction<R, P> queryFunction) {
        pageReqVO.setSqlFilters(SqlFilterCompiler.compile(pageReqVO.getFilters(), fieldMap));
        return selectPage(pageReqVO, queryFunction);
    }

    default <P extends PageParam, R> PageResult<R> selectPage(
            P pageReqVO,
            Function<IPage<R>, IPage<R>> queryFunction) {
        IPage<R> mpPage = new Page<>(pageReqVO.getPageNo(), pageReqVO.getPageSize());
        List<OrderItem> orderItems = buildOrderItem(pageReqVO);
        if (!CollectionUtils.isEmpty(orderItems)) {
            mpPage.orders().addAll(orderItems);
        }
        IPage<R> pageResult = queryFunction.apply(mpPage);
        return new PageResult<>(pageResult.getRecords(), pageResult.getTotal());
    }

    default <P extends SearchPageParam, R> PageResult<R> selectPageBySqlFilters(
            P pageReqVO,
            SqlSearchFieldMap fieldMap,
            Function<IPage<R>, IPage<R>> queryFunction) {
        pageReqVO.setSqlFilters(SqlFilterCompiler.compile(pageReqVO.getFilters(), fieldMap));
        return selectPage(pageReqVO, queryFunction);
    }

    List<OrderItem> buildOrderItem(PageParam pageParam);

}

六、方式一:MyBatis-Plus Wrapper Page 示例

PageReqVO

java 复制代码
@Schema(description = "管理后台 - 示例对象分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class DemoRecordPageReqVO extends SearchPageParam {

    @Schema(description = "记录编号")
    private String recordNo;

    @Schema(description = "记录名称")
    private String recordName;

    @Schema(description = "分类")
    private String category;

    @Schema(description = "状态")
    private Integer status;

}

Mapper

java 复制代码
@Mapper
public interface DemoRecordMapper extends BaseMapperX<DemoRecordDO> {

    default PageResult<DemoRecordDO> selectSearchPage(DemoRecordPageReqVO reqVO) {
        return selectPageByFilters(
                reqVO,
                new LambdaQueryWrapperX<DemoRecordDO>().orderByDesc(DemoRecordDO::getId),
                SearchFieldMap.<DemoRecordDO>builder()
                        .likeEqIn("recordNo", DemoRecordDO::getRecordNo)
                        .likeEqIn("recordName", DemoRecordDO::getRecordName)
                        .eqIn("category", DemoRecordDO::getCategory)
                        .eqIn("status", DemoRecordDO::getStatus)
                        .build()
        );
    }

}

Service

java 复制代码
@Service
public class DemoRecordServiceImpl implements DemoRecordService {

    @Resource
    private DemoRecordMapper demoRecordMapper;

    @Override
    public PageResult<DemoRecordDO> getDemoRecordSearchPage(DemoRecordPageReqVO pageReqVO) {
        return demoRecordMapper.selectSearchPage(pageReqVO);
    }

}

Controller

java 复制代码
@RestController
@RequestMapping("/demo/record")
public class DemoRecordController {

    @Resource
    private DemoRecordService demoRecordService;

    @PostMapping("/search-page")
    @Operation(summary = "获得示例对象分页 - 通用搜索")
    public CommonResult<PageResult<DemoRecordDO>> getDemoRecordSearchPage(
            @RequestBody DemoRecordPageReqVO pageVO) {
        return success(demoRecordService.getDemoRecordSearchPage(pageVO));
    }

}

七、方式二:XML Page 示例

PageReqVO

java 复制代码
@Schema(description = "管理后台 - 示例聚合分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class DemoAggregatePageReqVO extends SearchPageParam {

    @Schema(description = "记录编号")
    private String recordNo;
    private List<String> recordNoList;

    @Schema(description = "记录名称")
    private String recordName;

    @Schema(description = "关联编号")
    private String relatedNo;
    private List<String> relatedNoList;

    @Schema(description = "分类")
    private String category;
    private List<String> categoryList;

    @Schema(description = "状态")
    private Integer status;
    private List<Integer> statusList;

    @Schema(description = "金额")
    private BigDecimal[] amount;

    @Schema(description = "创建时间")
    private LocalDateTime[] createTime;

}

Mapper

java 复制代码
@Mapper
public interface DemoAggregateMapper extends BaseMapperX<DemoAggregateDO> {

    static SqlSearchFieldMap buildSearchFieldMap() {
        return SqlSearchFieldMap.builder()
                .likeEqIn("t", "recordNo")
                .likeEqIn("t", "recordName")
                .eqIn("t", "category")
                .eqIn("t", "status")
                .range("t", "amount")
                .range("t", "createTime")
                .add("relatedNo", "r.related_no", QueryOp.EQ, QueryOp.LIKE, QueryOp.IN)
                .build();
    }

    IPage<DemoAggregateRespVO> selectDemoAggregatePage(
            IPage<DemoAggregateRespVO> mpPage,
            @Param("reqVO") DemoAggregatePageReqVO pageReqVO
    );

}

主表常规字段可以使用快捷方法:

java 复制代码
.likeEqIn("t", "recordNo")
.eqIn("t", "status")
.range("t", "createTime")

联表字段或表达式字段使用显式映射:

java 复制代码
.add("relatedNo", "r.related_no", QueryOp.EQ, QueryOp.LIKE, QueryOp.IN)

Service

java 复制代码
@Service
public class DemoAggregateServiceImpl implements DemoAggregateService {

    @Resource
    private DemoAggregateMapper demoAggregateMapper;

    @Override
    public PageResult<DemoAggregateRespVO> getDemoAggregatePage(DemoAggregatePageReqVO pageReqVO) {
        return demoAggregateMapper.selectPageBySqlFilters(
                pageReqVO,
                DemoAggregateMapper.buildSearchFieldMap(),
                (mpPage, param) -> demoAggregateMapper.selectDemoAggregatePage(mpPage, param)
        );
    }

}

XML

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="DemoAggregateMapper">

    <resultMap id="DemoAggregateResultMap" type="DemoAggregateRespVO">
        <result column="attachment"
                property="attachment"
                typeHandler="ListFileDataTypeHandler"/>
    </resultMap>

    <sql id="demoAggregateQuery">
        <where>
            <if test="reqVO.relatedNo != null and reqVO.relatedNo != ''
                        or reqVO.relatedNoList != null and reqVO.relatedNoList.size() > 0">
                AND EXISTS (
                    SELECT 1
                    FROM demo_related_record r
                    WHERE r.main_id = t.id
                    AND r.deleted = 0

                    <if test="reqVO.relatedNo != null and reqVO.relatedNo != ''">
                        AND r.related_no LIKE CONCAT('%', #{reqVO.relatedNo}, '%')
                    </if>

                    <if test="reqVO.relatedNoList != null and reqVO.relatedNoList.size() > 0">
                        AND r.related_no IN
                        <foreach collection="reqVO.relatedNoList"
                                 item="item"
                                 open="("
                                 close=")"
                                 separator=",">
                            #{item}
                        </foreach>
                    </if>
                )
            </if>

            <if test="reqVO.sqlFilters != null and reqVO.sqlFilters.size > 0">
                <foreach collection="reqVO.sqlFilters" item="filter">
                    <choose>
                        <when test="filter.op.name() == 'EQ'">
                            AND ${filter.column} = #{filter.value}
                        </when>
                        <when test="filter.op.name() == 'LIKE'">
                            AND ${filter.column} LIKE CONCAT('%', #{filter.value}, '%')
                        </when>
                        <when test="filter.op.name() == 'IN'">
                            AND ${filter.column} IN
                            <foreach collection="filter.values"
                                     item="item"
                                     open="("
                                     separator=","
                                     close=")">
                                #{item}
                            </foreach>
                        </when>
                        <when test="filter.op.name() == 'BETWEEN'">
                            AND ${filter.column} BETWEEN #{filter.values[0]} AND #{filter.values[1]}
                        </when>
                        <when test="filter.op.name() == 'GT'">
                            AND ${filter.column} &gt; #{filter.value}
                        </when>
                        <when test="filter.op.name() == 'GE'">
                            AND ${filter.column} &gt;= #{filter.value}
                        </when>
                        <when test="filter.op.name() == 'LT'">
                            AND ${filter.column} &lt; #{filter.value}
                        </when>
                        <when test="filter.op.name() == 'LE'">
                            AND ${filter.column} &lt;= #{filter.value}
                        </when>
                    </choose>
                </foreach>
            </if>

            AND t.deleted = 0
        </where>
    </sql>

    <select id="selectDemoAggregatePage" resultMap="DemoAggregateResultMap">
        SELECT
            t.*,
            (
                SELECT COUNT(1)
                FROM demo_related_record r
                WHERE r.main_id = t.id
                AND r.deleted = 0
            ) AS related_count
        FROM demo_main_record t
        <include refid="demoAggregateQuery"/>
        ORDER BY t.id DESC
    </select>

</mapper>

八、SQL 注入风险说明

XML 中使用了:

xml 复制代码
${filter.column}

一般来说 ${} 有 SQL 注入风险,但这里的 filter.column 不是前端传入的,而是后端白名单编译出来的。

流程如下:

text 复制代码
前端 field = "recordNo"
        |
        v
SqlSearchFieldMap 白名单
        |
        v
"t.record_no"
        |
        v
XML ${filter.column}

如果前端传非法字段:

json 复制代码
{
  "field": "1=1 --",
  "op": "EQ",
  "value": "x"
}

后端会直接抛错:

java 复制代码
throw new ServiceException("不支持的查询字段:" + filter.getField());

所以安全边界在 Mapper 的 SearchFieldMap / SqlSearchFieldMap,不能绕过白名单直接拼字段。

九、请求效果

精准搜索

json 复制代码
{
  "field": "recordNo",
  "op": "EQ",
  "value": "R001"
}
sql 复制代码
AND t.record_no = ?

模糊搜索

json 复制代码
{
  "field": "recordNo",
  "op": "LIKE",
  "value": "R"
}
sql 复制代码
AND t.record_no LIKE CONCAT('%', ?, '%')

批量搜索

json 复制代码
{
  "field": "recordNo",
  "op": "IN",
  "values": ["A", "B", "C"]
}
sql 复制代码
AND t.record_no IN (?, ?, ?)

范围搜索

json 复制代码
{
  "field": "amount",
  "op": "BETWEEN",
  "values": [10, 20]
}
sql 复制代码
AND t.amount BETWEEN ? AND ?

总结

这套方案的核心是:

  1. 前端统一传 filters
  2. 后端统一定义 QueryOp
  3. MyBatis-Plus 查询使用 SearchFieldMap
  4. XML 查询使用 SqlSearchFieldMap
  5. 字段映射必须经过后端白名单
  6. Service 不维护字段映射,字段映射属于 Mapper 查询契约
  7. 特殊查询可以继续保留专门参数或 XML 条件

最终可以同时解决精准、模糊、批量、范围搜索的统一接入问题。

相关推荐
蓝缘9 小时前
解决main No MyBatis mapper was found in 的警告
mybatis
luteres13 小时前
Mybatis+SSM整合笔记
java·开发语言·mybatis
晚染烟1 天前
每日八股day31
mybatis
MetaLite2 天前
MyBatis复杂SQL很灵活-自研ORM为何仍要保留SQL逃生口
java·mybatis
步行cgn2 天前
MyBatis 实现分页查询
mybatis
山甫aa2 天前
Javaweb---MyBatis增删改查
java·数据库·后端·mybatis·springboot
choumou_M2 天前
SpringBoot_2:MyBatis-Generator插件使用教程
spring boot·tomcat·mybatis
SQL-First布道者2 天前
全面解构传统持久层框架,拥抱真正的 SQL-First
java·数据库·spring boot·sql·spring·mybatis·spring jdbc
晚安code4 天前
MyBatis-Plus 分页插件与字段自动填充:两行配置告别 CRUD 重复劳动
mybatis