功能:按资源配置平台列表搜索
This commit is contained in:
@@ -141,6 +141,13 @@ func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
if useConfiguredKeywordSearch(ctx) {
|
||||
conditions, arguments := configuredKeywordConditions(model, keyword)
|
||||
if len(conditions) == 0 {
|
||||
return query
|
||||
}
|
||||
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
|
||||
}
|
||||
columns := keywordColumns(model)
|
||||
if len(columns) == 0 {
|
||||
return query
|
||||
|
||||
163
backend/api/internal/logic/common/keyword_search.go
Normal file
163
backend/api/internal/logic/common/keyword_search.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Package common 提供平台资源可复用的配置化模糊搜索能力。
|
||||
// 版本:v1.0.0
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const configuredKeywordSearchContextKey = "configured_keyword_search"
|
||||
|
||||
// KeywordSearchKind 描述搜索字段的匹配方式。
|
||||
type KeywordSearchKind string
|
||||
|
||||
const (
|
||||
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
|
||||
KeywordSearchText KeywordSearchKind = "text"
|
||||
// KeywordSearchEnum 仅按页面展示的中文枚举名称匹配,不暴露内部英文编码。
|
||||
KeywordSearchEnum KeywordSearchKind = "enum"
|
||||
)
|
||||
|
||||
// KeywordSearchValue 描述枚举搜索中的可信编码与页面中文名称。
|
||||
type KeywordSearchValue struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// KeywordSearchField 描述一个资源允许搜索的可见字段。
|
||||
type KeywordSearchField struct {
|
||||
Key string `json:"key"`
|
||||
Kind KeywordSearchKind `json:"kind"`
|
||||
Values []KeywordSearchValue `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
var configuredKeywordPolicies = struct {
|
||||
sync.RWMutex
|
||||
fields map[reflect.Type][]KeywordSearchField
|
||||
}{fields: make(map[reflect.Type][]KeywordSearchField)}
|
||||
|
||||
// EnableConfiguredKeywordSearch 标记当前路由使用资源级搜索配置。
|
||||
// 返回值:Gin 中间件,仅影响挂载该中间件的平台总后台路由。
|
||||
func EnableConfiguredKeywordSearch() gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Set(configuredKeywordSearchContextKey, true)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterKeywordSearchPolicy 注册模型的搜索字段,并在启动阶段拒绝不安全配置。
|
||||
// 参数:model 必须为模型结构体指针;fields 只能引用安全白名单中的直接字符串列。
|
||||
func RegisterKeywordSearchPolicy(model any, fields []KeywordSearchField) {
|
||||
modelType := indirectModelType(model)
|
||||
validated := make([]KeywordSearchField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
validateKeywordSearchField(model, modelType, field)
|
||||
validated = append(validated, cloneKeywordSearchField(field))
|
||||
}
|
||||
configuredKeywordPolicies.Lock()
|
||||
defer configuredKeywordPolicies.Unlock()
|
||||
if _, exists := configuredKeywordPolicies.fields[modelType]; exists {
|
||||
panic("重复注册资源搜索策略:" + modelType.String())
|
||||
}
|
||||
configuredKeywordPolicies.fields[modelType] = validated
|
||||
}
|
||||
|
||||
// ConfiguredKeywordSearchFields 返回模型的只读搜索契约副本。
|
||||
func ConfiguredKeywordSearchFields(model any) []KeywordSearchField {
|
||||
modelType := indirectModelType(model)
|
||||
configuredKeywordPolicies.RLock()
|
||||
defer configuredKeywordPolicies.RUnlock()
|
||||
fields := configuredKeywordPolicies.fields[modelType]
|
||||
result := make([]KeywordSearchField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
result = append(result, cloneKeywordSearchField(field))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func useConfiguredKeywordSearch(ctx *gin.Context) bool {
|
||||
enabled, exists := ctx.Get(configuredKeywordSearchContextKey)
|
||||
return exists && enabled == true
|
||||
}
|
||||
|
||||
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
|
||||
// 枚举字段只接受中文展示名称,普通文本字段保持原有包含匹配行为。
|
||||
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
||||
fields := ConfiguredKeywordSearchFields(model)
|
||||
conditions := make([]string, 0, len(fields))
|
||||
arguments := make([]any, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
switch field.Kind {
|
||||
case KeywordSearchText:
|
||||
conditions = append(conditions, `LOWER("`+field.Key+`") LIKE ?`)
|
||||
arguments = append(arguments, "%"+keyword+"%")
|
||||
case KeywordSearchEnum:
|
||||
values := matchingKeywordEnumValues(field.Values, keyword)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",")
|
||||
conditions = append(conditions, `"`+field.Key+`" IN (`+placeholders+")")
|
||||
for _, value := range values {
|
||||
arguments = append(arguments, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return conditions, arguments
|
||||
}
|
||||
|
||||
func matchingKeywordEnumValues(values []KeywordSearchValue, keyword string) []string {
|
||||
matched := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if strings.Contains(strings.ToLower(value.Label), keyword) {
|
||||
matched = append(matched, value.Value)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func validateKeywordSearchField(model any, modelType reflect.Type, field KeywordSearchField) {
|
||||
if !keywordSafeColumns[field.Key] || isSensitiveKeywordColumn(model, field.Key) {
|
||||
panic("资源搜索字段不在安全白名单中:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
if !hasDirectStringColumn(modelType, field.Key) {
|
||||
panic("资源搜索字段不是模型直接字符串列:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
if field.Kind != KeywordSearchText && field.Kind != KeywordSearchEnum {
|
||||
panic("资源搜索字段类型无效:" + string(field.Kind))
|
||||
}
|
||||
if field.Kind == KeywordSearchEnum && len(field.Values) == 0 {
|
||||
panic("枚举搜索字段缺少中文值:" + modelType.String() + "." + field.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func hasDirectStringColumn(modelType reflect.Type, column string) bool {
|
||||
for index := 0; index < modelType.NumField(); index++ {
|
||||
field := modelType.Field(index)
|
||||
if !field.Anonymous && field.Type.Kind() == reflect.String && gormColumn(field.Tag.Get("gorm")) == column {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func indirectModelType(model any) reflect.Type {
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
panic("资源搜索模型必须为结构体或结构体指针")
|
||||
}
|
||||
return modelType
|
||||
}
|
||||
|
||||
func cloneKeywordSearchField(field KeywordSearchField) KeywordSearchField {
|
||||
clone := field
|
||||
clone.Values = append([]KeywordSearchValue(nil), field.Values...)
|
||||
return clone
|
||||
}
|
||||
76
backend/api/internal/logic/common/keyword_search_test.go
Normal file
76
backend/api/internal/logic/common/keyword_search_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keywordSearchEnumModel 用于验证中文枚举别名不会退化为英文编码搜索。
|
||||
type keywordSearchEnumModel struct {
|
||||
RoleCode string `gorm:"column:role_code"`
|
||||
}
|
||||
|
||||
// keywordSearchTextModel 用于验证普通文本字段继续执行包含匹配。
|
||||
type keywordSearchTextModel struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
|
||||
var registerKeywordSearchTestPolicies sync.Once
|
||||
|
||||
// registerKeywordSearchPoliciesForTest 保证单测可独立或组合运行。
|
||||
func registerKeywordSearchPoliciesForTest() {
|
||||
registerKeywordSearchTestPolicies.Do(func() {
|
||||
RegisterKeywordSearchPolicy(&keywordSearchEnumModel{}, []KeywordSearchField{{
|
||||
Key: "role_code",
|
||||
Kind: KeywordSearchEnum,
|
||||
Values: []KeywordSearchValue{
|
||||
{Value: "installer", Label: "安装人员"},
|
||||
{Value: "delivery", Label: "配送人员"},
|
||||
{Value: "operations", Label: "运维人员"},
|
||||
},
|
||||
}})
|
||||
RegisterKeywordSearchPolicy(&keywordSearchTextModel{}, []KeywordSearchField{{
|
||||
Key: "name", Kind: KeywordSearchText,
|
||||
}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordConditionsMatchChineseEnumLabels(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
|
||||
conditions, arguments := configuredKeywordConditions(&keywordSearchEnumModel{}, "人员")
|
||||
if !reflect.DeepEqual(conditions, []string{`"role_code" IN (?,?,?)`}) {
|
||||
t.Fatalf("中文枚举条件不符合预期:%v", conditions)
|
||||
}
|
||||
if !reflect.DeepEqual(arguments, []any{"installer", "delivery", "operations"}) {
|
||||
t.Fatalf("中文枚举编码不符合预期:%v", arguments)
|
||||
}
|
||||
|
||||
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
|
||||
if len(conditions) != 0 || len(arguments) != 0 {
|
||||
t.Fatalf("英文枚举编码不应继续可搜:conditions=%v arguments=%v", conditions, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordConditionsKeepTextFuzzySearch(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
|
||||
conditions, arguments := configuredKeywordConditions(&keywordSearchTextModel{}, "气站")
|
||||
if !reflect.DeepEqual(conditions, []string{`LOWER("name") LIKE ?`}) {
|
||||
t.Fatalf("普通文本条件不符合预期:%v", conditions)
|
||||
}
|
||||
if !reflect.DeepEqual(arguments, []any{"%气站%"}) {
|
||||
t.Fatalf("普通文本参数不符合预期:%v", arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredKeywordSearchFieldsReturnsCopy(t *testing.T) {
|
||||
registerKeywordSearchPoliciesForTest()
|
||||
fields := ConfiguredKeywordSearchFields(&keywordSearchEnumModel{})
|
||||
fields[0].Values[0].Label = "已篡改"
|
||||
again := ConfiguredKeywordSearchFields(&keywordSearchEnumModel{})
|
||||
if again[0].Values[0].Label != "安装人员" {
|
||||
t.Fatal("搜索契约必须返回副本,避免调用方修改全局策略")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user