Files
platforms/backend/api/internal/logic/common/keyword_search.go
2026-08-11 22:23:02 +08:00

164 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}