第34章:关键词检索体系
任务列表、素材列表等多个查询接口都支持 keywords 参数:"," 表与、"|" 表或,如 发布会,精剪|一键 表示"(发布会 AND 精剪)OR 一键"。本章解析这套小型查询 DSL 的两层实现——service/keywords.go 负责解析,repository/keyword_filter.go 负责翻译为 SQL。
流程
表达式先在服务层解析为二维结构 KeywordGroups:外层是 OR 组,内层是 AND 词。仓储层再把每组拼成 SQL 片段,组间 OR 连接,最终成为一条 WHERE 子句。
flowchart LR
A["游戏,周末|发布会,2026"] --> B["parseKeywordExpr<br/>service 层"]
B --> C["[["游戏","周末"],<br/>["发布会","2026"]]"]
C --> D["applyKeywordGroups<br/>repository 层"]
D --> E["WHERE ((t.video_project_name LIKE ? AND LIKE ?)<br/>OR (LIKE ? AND LIKE ?))"]
E --> F["参数依次绑定<br/>%游戏% %周末% %发布会% %2026%"]
匹配目标由调用方决定:任务列表模糊匹配 video_project_name,素材列表匹配 name 等字段——termClause 参数化的设计让同一套分组逻辑可复用于任意表与任意列组合。
实现
parseKeywordExpr 的健壮性体现在"三层清洗":先 Trim 整串、再丢弃空 OR 段、再对组内每个词 Trim + 转小写 + 丢弃空词。因此 "游戏,,周末 | " 这类含杂质输入也能安全解析为 [["游戏","周末"]],不会产生空串 LIKE(空 pattern 的 % 会匹配所有行,语义灾难)。
小写化的动机:不同字段在 SQL 侧用 LOWER(col) LIKE ? 匹配(termClause 由调用方决定大小写处理),解析层统一小写保证两侧约定一致——Go 侧 strings.ToLower 与 PostgreSQL 的 LOWER 对中文行为无差,对英文等场景则对齐了语义。
applyKeywordGroups 是典型的参数化 SQL 组装:termClause 是含 ? 占位符的字符串模板(如 (LOWER(t.video_project_name) LIKE ?)),每个词按 argsPerTerm 重复绑定同一 %kw% pattern。整个 WHERE 串的每个占位符都有对应实参,全程零字符串拼接用户输入——没有注入面。空组、空 clause、非正参数个数的守卫让函数对任何非法输入都直接返回原查询。
分组的 SQL 形态是 (A AND B) OR (C AND D)——括号保证 AND/OR 优先级不被误解,这是手工拼布尔逻辑时最常见的事故点,这里用 strings.Join 加显式括号彻底规避。
📌 设计决策
- 逗号=AND、竖线=OR 的选型:与多数电商筛选习惯(逗号表并列 OR)相反,这里取"逗号收窄、竖线扩展"——搜索"发布会 精剪"时用户预期是同时满足。文档与 Swagger 注释里反复强调该语义。
- 词法层下沉服务、SQL 层下沉仓储:解析不碰数据库,组装不碰字符串清洗,两层各自可测;
KeywordGroups是唯一契约类型。 - 不引入全文检索引擎:LIKE
%kw%无法走索引,但项目规模(单实例、万级任务量)下足够;换 pg_trgm 或 ES 的成本在当前阶段不划算。 - termClause 参数化而非列名硬编码:同一函数可对多列(
name LIKE ? OR remark LIKE ?,argsPerTerm=2)复用,扩展一个搜索面只改一个字符串。
代码示例
片段 1 展示表达式解析(摘自 service/keywords.go,完整函数):
// parseKeywordExpr 解析关键词表达式:
// - "," 分隔的词为「与」(AND)
// - "|" 分隔的组为「或」(OR)
//
// 例:"游戏,周末|发布会,2026" => [["游戏","周末"],["发布会","2026"]]。
// 空白与空段会被丢弃;词统一转小写。
func parseKeywordExpr(raw string) repository.KeywordGroups {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
orParts := strings.Split(raw, "|")
groups := make(repository.KeywordGroups, 0, len(orParts))
for _, orPart := range orParts {
orPart = strings.TrimSpace(orPart)
if orPart == "" {
continue
}
andParts := strings.Split(orPart, ",")
group := make([]string, 0, len(andParts))
for _, part := range andParts {
kw := strings.ToLower(strings.TrimSpace(part))
if kw != "" {
group = append(group, kw)
}
}
if len(group) > 0 {
groups = append(groups, group)
}
}
if len(groups) == 0 {
return nil
}
return groups
}
片段 2 展示 SQL 组装(摘自 repository/keyword_filter.go,完整核心函数):
// applyKeywordGroups 将关键词表达式应用到查询:组内 AND、组间 OR。
// termClause 为单个词的匹配 SQL(可含多个 ?),argsPerTerm 为每个词需要绑定的参数个数;
// 每个 ? 都会绑定同一 pattern "%kw%"。
func applyKeywordGroups(query *gorm.DB, groups KeywordGroups, termClause string, argsPerTerm int) *gorm.DB {
if KeywordGroupsEmpty(groups) || termClause == "" || argsPerTerm <= 0 {
return query
}
var orParts []string
args := make([]interface{
}, 0)
for _, group := range groups {
if len(group) == 0 {
continue
}
andParts := make([]string, 0, len(group))
for _, kw := range group {
andParts = append(andParts, "("+termClause+")")
pattern := "%" + kw + "%"
for i := 0; i < argsPerTerm; i++ {
args = append(args, pattern)
}
}
orParts = append(orParts, "("+strings.Join(andParts, " AND ")+")")
}
if len(orParts) == 0 {
return query
}
return query.Where(strings.Join(orParts, " OR "), args...)
}
小结
不到一百行代码支撑了全系统的搜索筛选:一层词法、一层 SQL 翻译,中间用一个二维字符串切片做契约。参数化占位符加显式括号,让"动态拼 WHERE"这一高危操作保持了零注入面。
思考题
- 若用户输入
游戏,周末,发布会,直播,带货,讲解,拆解,复盘(一个 8 词 AND 组),LIKE 链条对查询计划的性能影响如何?是否需要限制组内词数上限? - 关键词统一小写后,若 termClause 未配合
LOWER(col),英文大小写匹配会静默失效——这类跨层契约你会用什么机制(测试、断言、注释规范)来守住?
项目信息
- GitHub仓库:Chyona/live-mixer