背景
分页查询中常见三类搜索:
| 搜索模式 | SQL 语义 |
|---|---|
| 模糊搜索 | LIKE '%value%' |
| 精准搜索 | = value |
| 批量搜索 | IN (v1, v2, v3) |
如果每个字段都手写单值参数、批量参数、LIKE、IN,Mapper 和 XML 很快会膨胀。本文实现一套通用搜索协议,同时支持:
- MyBatis-Plus Wrapper 分页
- XML 复杂 SQL 分页
EQ / LIKE / INBETWEEN / 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} > #{filter.value}
</when>
<when test="filter.op.name() == 'GE'">
AND ${filter.column} >= #{filter.value}
</when>
<when test="filter.op.name() == 'LT'">
AND ${filter.column} < #{filter.value}
</when>
<when test="filter.op.name() == 'LE'">
AND ${filter.column} <= #{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 ?
总结
这套方案的核心是:
- 前端统一传
filters - 后端统一定义
QueryOp - MyBatis-Plus 查询使用
SearchFieldMap - XML 查询使用
SqlSearchFieldMap - 字段映射必须经过后端白名单
- Service 不维护字段映射,字段映射属于 Mapper 查询契约
- 特殊查询可以继续保留专门参数或 XML 条件
最终可以同时解决精准、模糊、批量、范围搜索的统一接入问题。