feat: 统一列表模糊搜索行为

- 统一管理端和用户中心搜索参数及多字段包含匹配

- 修复聊天搜索竞态并优化部门重名路径展示

- 补充部门展开、模型空白词和聊天查询回归测试
This commit is contained in:
2026-08-13 22:29:59 +08:00
parent 765006747a
commit 64a85c6a5b
83 changed files with 1134 additions and 208 deletions

View File

@@ -0,0 +1,35 @@
package tech.easyflow.common.util;
/**
* 搜索关键词处理工具。
*/
public final class SearchKeywordUtil {
private SearchKeywordUtil() {
}
/**
* 去除搜索关键词首尾空格,空值统一为空字符串。
*
* @param keyword 原始搜索关键词
* @return 规范化后的关键词
*/
public static String normalize(String keyword) {
return keyword == null ? "" : keyword.trim();
}
/**
* 构造将 LIKE 通配符按普通字符处理的包含匹配模式。
*
* @param keyword 搜索关键词
* @return 可参数化传入 LIKE 的匹配模式
*/
public static String literalContainsPattern(String keyword) {
String normalizedKeyword = normalize(keyword);
String escapedKeyword = normalizedKeyword
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
return "%" + escapedKeyword + "%";
}
}

View File

@@ -1,6 +1,8 @@
package tech.easyflow.common.web.controller;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryColumn;
import com.mybatisflex.core.query.QueryCondition;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.core.query.SqlOperators;
import com.mybatisflex.core.service.IService;
@@ -15,6 +17,7 @@ import tech.easyflow.common.domain.Result;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.tree.Tree;
import tech.easyflow.common.util.SearchKeywordUtil;
import tech.easyflow.common.util.SqlOperatorsUtil;
import tech.easyflow.common.util.SqlUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -30,6 +33,7 @@ import java.util.*;
public class BaseCurdController<S extends IService<M>, M> extends BaseController {
private static final String KEYWORD_PARAM = "keyword";
protected final S service;
@@ -198,6 +202,7 @@ public class BaseCurdController<S extends IService<M>, M> extends BaseController
Map<String, String> propertyColumnMapping = TableInfoFactory.ofEntityClass(getEntityClass())
.getPropertyColumnMapping();
QueryCondition legacyOrCondition = null;
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
String paramKey = entry.getKey();
if (StringUtil.hasText(paramKey) && !paramKey.endsWith(OperatorBuilder.operatorSuffix) && propertyColumnMapping.containsKey(paramKey)) {
@@ -216,9 +221,14 @@ public class BaseCurdController<S extends IService<M>, M> extends BaseController
queryWrapper.eq(columnName, value);
} else {
if (isQueryOrBool) {
queryWrapper.or(columnName + " like " + "'%" + value + "%' ");
QueryCondition condition = buildLiteralContainsCondition(
value, new QueryColumn(columnName));
legacyOrCondition = legacyOrCondition == null
? condition
: legacyOrCondition.or(condition);
} else {
queryWrapper.like(columnName, value);
queryWrapper.and(buildLiteralContainsCondition(
value, new QueryColumn(columnName)));
}
}
}
@@ -227,9 +237,101 @@ public class BaseCurdController<S extends IService<M>, M> extends BaseController
}
}
if (legacyOrCondition != null) {
queryWrapper.and(legacyOrCondition);
}
applyKeywordSearch(queryWrapper, request, propertyColumnMapping);
return queryWrapper;
}
/**
* 获取当前控制器统一关键字需要匹配的实体属性。
*
* <p>属性名必须来自当前实体映射。分类、状态、租户等精确条件不应放入该数组。</p>
*
* @return 需要执行包含匹配的实体属性;默认不启用统一关键字
*/
protected String[] getKeywordSearchProperties() {
return new String[0];
}
/**
* 归一化搜索关键字。
*
* @param keyword 原始关键字
* @return 去除首尾空格后的关键字;空值返回空字符串
*/
protected String normalizeSearchKeyword(String keyword) {
return SearchKeywordUtil.normalize(keyword);
}
/**
* 构造按普通文本解释的多列包含匹配条件。
*
* @param keyword 原始关键字
* @param columns 允许搜索的受信任列
* @return 已正确分组的 OR 条件
* @throws IllegalArgumentException 关键字为空或未提供列时抛出
*/
protected QueryCondition buildLiteralContainsCondition(String keyword, QueryColumn... columns) {
String normalizedKeyword = normalizeSearchKeyword(keyword);
if (!StringUtil.hasText(normalizedKeyword) || columns == null || columns.length == 0) {
throw new IllegalArgumentException("keyword and columns must not be empty");
}
String pattern = buildLiteralContainsPattern(normalizedKeyword);
QueryCondition condition = null;
for (QueryColumn column : columns) {
if (column == null) {
continue;
}
QueryCondition columnCondition = column.likeRaw(pattern);
condition = condition == null ? columnCondition : condition.or(columnCondition);
}
if (condition == null) {
throw new IllegalArgumentException("columns must contain at least one valid column");
}
return condition;
}
/**
* 构造将 LIKE 通配符按普通字符处理的包含匹配模式。
*
* @param keyword 已归一化的关键字
* @return 可参数化传入 LIKE 的匹配模式
*/
protected String buildLiteralContainsPattern(String keyword) {
return SearchKeywordUtil.literalContainsPattern(keyword);
}
/**
* 将控制器声明的统一关键字字段追加到查询条件。
*
* @param queryWrapper 查询包装器
* @param request 当前请求
* @param propertyColumnMapping 实体属性与数据库列映射
* @throws ProgramException 控制器声明了不存在的实体属性时抛出
*/
private void applyKeywordSearch(QueryWrapper queryWrapper,
HttpServletRequest request,
Map<String, String> propertyColumnMapping) {
String keyword = normalizeSearchKeyword(request.getParameter(KEYWORD_PARAM));
String[] properties = getKeywordSearchProperties();
if (!StringUtil.hasText(keyword) || properties == null || properties.length == 0) {
return;
}
QueryColumn[] columns = new QueryColumn[properties.length];
for (int index = 0; index < properties.length; index++) {
String property = properties[index];
String column = propertyColumnMapping.get(property);
if (!StringUtil.hasText(column)) {
throw new ProgramException("搜索字段未映射到实体属性:" + property);
}
columns[index] = new QueryColumn(column);
}
queryWrapper.and(buildLiteralContainsCondition(keyword, columns));
}
protected Class<?> getEntityClass() {
Type type = getClass().getGenericSuperclass();
if (type instanceof ParameterizedType) {