Files
platforms/backend/api/internal/logic/common/keyword_search.go

164 lines
5.7 KiB
Go
Raw Normal View History

// 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
}