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