功能:按资源配置平台列表搜索

This commit is contained in:
czl231
2026-08-11 22:22:36 +08:00
parent 8d6ffca1e2
commit 95d0f7dc54
19 changed files with 741 additions and 62 deletions

View File

@@ -15,6 +15,7 @@ import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery"
gaslogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/gas"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform"
@@ -79,11 +80,12 @@ type route struct {
}
type contract struct {
Domain string `json:"domain"`
Name string `json:"name"`
Path string `json:"path"`
PageKind string `json:"pageKind"`
Mode string `json:"mode"`
Domain string `json:"domain"`
Name string `json:"name"`
Path string `json:"path"`
PageKind string `json:"pageKind"`
Mode string `json:"mode"`
SearchFields []common.KeywordSearchField `json:"searchFields,omitempty"`
}
type manifest struct {
@@ -107,7 +109,7 @@ func writeResourceContract(output io.Writer) error {
for _, item := range expected {
contracts = append(contracts, contract{
Domain: item.Domain, Name: item.Name, Path: item.Path,
PageKind: item.PageKind, Mode: string(item.Mode),
PageKind: item.PageKind, Mode: string(item.Mode), SearchFields: item.SearchFields,
})
}
return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes})

View File

@@ -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

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

View 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("搜索契约必须返回副本,避免调用方修改全局策略")
}
}

View File

@@ -20,11 +20,12 @@ const (
// ResourceContract is the expected cross-layer representation of one resource.
type ResourceContract struct {
Domain string
Name string
Path string
PageKind string
Mode ResourceMode
Domain string
Name string
Path string
PageKind string
Mode ResourceMode
SearchFields []common.KeywordSearchField
}
// ResourceDefinition describes a route resource and the fields it may change.
@@ -94,7 +95,10 @@ func ExpectedResources() []ResourceContract {
}
func resourceContract(domain, name string, mode ResourceMode, pageKind string) ResourceContract {
return ResourceContract{Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode, PageKind: pageKind}
return ResourceContract{
Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode,
PageKind: pageKind, SearchFields: resourceSearchFields(name),
}
}
func resourcePath(domain, name string) string {

View File

@@ -0,0 +1,73 @@
// Package platform 定义平台总后台各资源的可见字段搜索策略。
// 版本v1.0.0
package platform
import (
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
)
type resourceSearchDefinition struct {
name string
model any
fields []common.KeywordSearchField
}
var resourceSearchDefinitions = []resourceSearchDefinition{
searchDefinition("gas_basic", &models.GasBasic{}, text("code"), text("name")),
searchDefinition("gas_account", &models.GasAccount{}, text("username"), text("display_name"), enum("role_code", value("admin", "气站管理员"))),
searchDefinition("delivery_basic", &models.DeliveryBasic{}, text("delivery_code"), text("name")),
searchDefinition("delivery_account", &models.DeliveryAccount{}, text("username"), text("display_name"), enum("role_code", value("admin", "配送点管理员"))),
searchDefinition("staff_account", &models.StaffAccount{}, text("username"), enum("role_code", value("installer", "安装人员"), value("delivery", "配送人员"), value("operations", "运维人员"))),
searchDefinition("staff_credential", &models.StaffCredential{}, text("credential_type")),
searchDefinition("user_account", &models.UserAccount{}, text("username")),
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
searchDefinition("product_type", &models.ProductType{}, text("code"), text("name")),
searchDefinition("product_warehouse", &models.ProductWarehouse{}, text("code"), text("name")),
searchDefinition("product_info", &models.ProductInfo{}, text("code"), text("name")),
searchDefinition("product_repair", &models.ProductRepair{}, enum("result", value("pending", "待处理"), value("passed", "通过"), value("failed", "未通过"))),
searchDefinition("ec_product", &models.EcProduct{}, text("product_code"), text("name")),
searchDefinition("ec_product_attribute", &models.EcProductAttribute{}, text("name"), text("value")),
searchDefinition("gasorder_contract", &models.GasorderContract{}, text("contract_no"), text("title")),
searchDefinition("gasorder_basic", &models.GasorderBasic{}, text("request_no"), enum("creator_type", value("user", "用户"), value("staff", "工作人员"), value("delivery", "配送站"), value("gas", "气站"))),
searchDefinition("fin_settlement", &models.FinSettlement{}, text("settlement_no"), text("subject_type")),
searchDefinition("cms_content", &models.CmsContent{}, text("content_type"), text("title"), text("publish_status")),
searchDefinition("cs_ticket", &models.CsTicket{}, text("ticket_no"), text("category"), text("priority")),
searchDefinition("platform_account", &models.PlatformAccount{}, text("username"), text("display_name")),
searchDefinition("platform_role", &models.PlatformRole{}, text("role_code"), text("name"), enum("location_scope", value("standard", "脱敏坐标"), value("precise", "精确坐标"))),
searchDefinition("wallet_basic", &models.WalletBasic{}, text("owner_type")),
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no")),
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no")),
}
func init() {
for _, definition := range resourceSearchDefinitions {
common.RegisterKeywordSearchPolicy(definition.model, definition.fields)
}
}
// resourceSearchFields 返回资源公开搜索契约的副本;未配置资源明确不支持搜索。
func resourceSearchFields(name string) []common.KeywordSearchField {
for _, definition := range resourceSearchDefinitions {
if definition.name == name {
return common.ConfiguredKeywordSearchFields(definition.model)
}
}
return nil
}
func searchDefinition(name string, model any, fields ...common.KeywordSearchField) resourceSearchDefinition {
return resourceSearchDefinition{name: name, model: model, fields: fields}
}
func text(key string) common.KeywordSearchField {
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchText}
}
func enum(key string, values ...common.KeywordSearchValue) common.KeywordSearchField {
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchEnum, Values: values}
}
func value(code, label string) common.KeywordSearchValue {
return common.KeywordSearchValue{Value: code, Label: label}
}

View File

@@ -0,0 +1,51 @@
package platform
import "testing"
func TestResourceSearchContractMatchesConfirmedScope(t *testing.T) {
tests := []struct {
resource string
keys []string
}{
{resource: "staff_account", keys: []string{"username", "role_code"}},
{resource: "user_address", keys: nil},
{resource: "platform_account", keys: []string{"username", "display_name"}},
{resource: "product_repair", keys: []string{"result"}},
}
contracts := ExpectedResources()
for _, test := range tests {
contract := findResourceContract(contracts, test.resource)
if contract == nil {
t.Fatalf("资源契约不存在:%s", test.resource)
}
if len(contract.SearchFields) != len(test.keys) {
t.Fatalf("%s 搜索字段数量=%d期望=%d", test.resource, len(contract.SearchFields), len(test.keys))
}
for index, key := range test.keys {
if contract.SearchFields[index].Key != key {
t.Fatalf("%s 第 %d 个搜索字段=%s期望=%s", test.resource, index, contract.SearchFields[index].Key, key)
}
}
}
}
func TestStaffRoleSearchUsesChineseLabels(t *testing.T) {
contract := findResourceContract(ExpectedResources(), "staff_account")
role := contract.SearchFields[1]
if role.Kind != "enum" || len(role.Values) != 3 {
t.Fatalf("工作人员角色搜索契约不完整:%+v", role)
}
if role.Values[1].Value != "delivery" || role.Values[1].Label != "配送人员" {
t.Fatalf("工作人员角色中英文映射错误:%+v", role.Values[1])
}
}
func findResourceContract(contracts []ResourceContract, name string) *ResourceContract {
for index := range contracts {
if contracts[index].Name == name {
return &contracts[index]
}
}
return nil
}

View File

@@ -32,6 +32,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) {
protected := engine.Group(basePath)
protected.Use(middleware.JwtAuth(true))
protected.Use(platformlogic.RequirePlatformMenuAccess())
protected.Use(common.EnableConfiguredKeywordSearch())
protected.GET("/auth/profile", platformbase.CurrentProfile)
protected.PUT("/auth/password", platformbase.ChangePassword)
protected.GET("/dashboard/overview", dashboard.DashboardOverview)