搜索项和属性拆开 —— 搜索标签的诞生

需求背景

随着搜索维度越来越多,产品侧开始反馈:搜索栏太乱了,用户找不到想要的条件。

具体有三个问题:

一、搜索项太多,一屏显示不下。 用户体验很差,需要滚动才能看到所有选项。

二、归类不清。 【作者朝代】、【出版年代】、【特殊历史时期】(文艺复兴、工业革命、一战、二战)这些搜索项分散在各处,业务希望把它们聚合到同一个分类下,统称【历史时期】。

三、无法表达组合规则。 业务想上线一个搜索项叫【好评如潮】,条件是"4星且评论数大于3000"。这是一个多属性组合的规则,当前架构里搜索项和属性是一一对应的,根本表达不了。

核心矛盾

当前架构的根本问题是:搜索项和实体属性是强绑定的,一个搜索项对应一个属性字段,没有中间层。

这导致搜索项既不能分类组织,也不能组合多个属性,更不能灵活配置。每次新增一个搜索项,都要改后端接口、改前端页面、联调上线,耗时 2-3 天。

解决方案的方向很清晰:在搜索项和属性之间加一层抽象,让搜索项变成一个独立的配置对象,可以自由组合属性规则。

方案设计

核心设计思路:搜索项在后端眼里,就是一套实体属性的布尔运算规则,对应一段 ES 查询 DSL。

新增两张配置表:

搜索标签配置表(每个搜索项对应一条记录):

create table search_tag_config (
  tag_id          bigint primary key comment '搜索项ID',
  tag_name        varchar(255) comment '搜索项名称',
  category_id     bigint comment '搜索项分类ID',
  search_tag_dsl  text comment '搜索项映射的 ES 搜索 DSL'
)

搜索分类配置表(控制搜索项的分组和多选逻辑):

create table search_category_config (
  category_id     bigint primary key comment '搜索项分类ID',
  category_name   varchar(255) comment '搜索项分类名称',
  allow_multi_choice int comment '是否允许多选,1=是,2=否',
  union_type      int comment '多选时的聚合类型,1=AND,2=OR'
)

前后端交互

前端拿到的是分类 + 搜索项的树形结构:

{
  "searchCategoryList": [
    {
      "categoryName": "历史时期",
      "allowMultiChoice": 1,
      "unionType": 2,
      "searchTagList": [
        { "tagName": "作者朝代", "tagId": 1 },
        { "tagName": "出版年代", "tagId": 2 },
        { "tagName": "文艺复兴", "tagId": 3 }
      ]
    },
    {
      "categoryName": "图书质量",
      "allowMultiChoice": 0,
      "unionType": 1,
      "searchTagList": [
        { "tagName": "好评如潮", "tagId": 10 }
      ]
    }
  ]
}

用户勾选后,前端只传 tagId 列表,后端根据配置自动组装 ES 查询 DSL:

public QueryBuilder buildSearchQuery(List<Long> tagIds) {
    List<SearchTagConfig> tagConfigs = searchTagRepository.findByTagIdIn(tagIds);
    Map<Long, List<SearchTagConfig>> categoryGroups =
        tagConfigs.stream().collect(Collectors.groupingBy(SearchTagConfig::getCategoryId));

    BoolQueryBuilder rootQuery = QueryBuilders.boolQuery();

    for (Map.Entry<Long, List<SearchTagConfig>> entry : categoryGroups.entrySet()) {
        SearchCategoryConfig categoryConfig =
            searchCategoryRepository.findById(entry.getKey());
        BoolQueryBuilder categoryQuery = QueryBuilders.boolQuery();

        for (SearchTagConfig tagConfig : entry.getValue()) {
            QueryBuilder tagQuery = parseDsl(tagConfig.getSearchTagDsl());
            if (categoryConfig.getUnionType() == 1) {
                categoryQuery.must(tagQuery);   // AND
            } else {
                categoryQuery.should(tagQuery); // OR
            }
        }
        rootQuery.must(categoryQuery);
    }
    return rootQuery;
}

DSL 示例

简单属性搜索项(作者朝代 = 唐朝):

{ "term": { "author_dynasty": "唐朝" } }

组合属性搜索项(好评如潮 = 4星 AND 评论数>3000):

{
  "bool": {
    "must": [
      { "term":  { "star_level.level": "4星" } },
      { "range": { "star_level.comment_count": { "gt": 3000 } } }
    ]
  }
}

新增搜索项的成本对比

优化前:改后端接口 → 改前端页面 → 联调测试 → 上线部署,耗时 2-3 天

优化后:写 ES DSL → 插入配置表,耗时 2 小时,无需上线。

维度优化前优化后
新增搜索项周期2-3 天2 小时
前端改动每次必须无需
属性组合支持不支持完全支持
分类组织代码固化配置化

这一步解决了什么

搜索项从此变成了可配置、可管理的独立对象。它不再和某个具体属性绑定,而是对应一套灵活的查询规则。新增搜索项只需要写 DSL 配置,无需代码开发。

但用户很快提出了新需求:搜索结果列表里,能不能显示每本书命中了哪些搜索条件?

这个需求看起来只是个展示问题,但它引出了一个更深层的设计——搜索项和标签,其实是同一件事。

这引出了下一篇:标签系统的诞生。