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 条件

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

相关推荐
XiYang-DING3 小时前
【MyBatis】注释方式实现CRUD
mybatis
XiYang-DING4 小时前
【MyBatis】XML方式实现CRUD
xml·mybatis
小饼干在学嘎瓦4 小时前
秒杀场景Redis做预扣减,问题在哪里?
数据库·redis·mybatis
来杯@Java14 小时前
图书管理系统(基于springboot+vue前后端分离的项目)计算机毕业设计java
java·spring boot·spring·vue·毕业设计·mybatis·课程设计
Pluchon1 天前
萌萌技术分享笔记——Java综合项目
java·开发语言·笔记·git·github·mybatis·postman
骄马之死1 天前
MyBatis SqlSession 与缓存机制详解
mysql·mybatis
IronMurphy2 天前
SSM拷打第二讲!!!
java·spring·mybatis
C+-C资深大佬3 天前
SSM 框架(Spring + SpringMVC + MyBatis)
java·spring·mybatis
二王一个今3 天前
springboot security 权限控制---循环依赖问题
mybatis