完善配送端资源搜索与资质导航
This commit is contained in:
@@ -188,7 +188,8 @@ func writeDeliveryResourceContract(output io.Writer) error {
|
||||
contracts := make([]contract, 0, len(expected))
|
||||
for _, item := range expected {
|
||||
contracts = append(contracts, contract{
|
||||
Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: item.Mode,
|
||||
Domain: item.Domain, Name: item.Name, Path: item.Path,
|
||||
PageKind: item.PageKind, Mode: item.Mode, SearchFields: item.SearchFields,
|
||||
})
|
||||
}
|
||||
return json.NewEncoder(output).Encode(manifest{Resources: contracts, Routes: routes})
|
||||
|
||||
@@ -119,7 +119,7 @@ func ListPageFiltered[T any](ctx *gin.Context, filter func(*gorm.DB) *gorm.DB) {
|
||||
var keywordSafeColumns = map[string]bool{
|
||||
"code": true, "name": true, "username": true, "display_name": true,
|
||||
"role_code": true, "delivery_code": true, "work_status": true,
|
||||
"credential_type": true, "device_no": true, "model": true,
|
||||
"credential_type": true, "credential_no": true, "device_no": true, "model": true,
|
||||
"online_status": true, "rule_code": true, "action": true,
|
||||
"event_code": true, "title": true, "result": true,
|
||||
"product_code": true, "value": true, "order_no": true,
|
||||
|
||||
@@ -18,7 +18,7 @@ type KeywordSearchKind string
|
||||
const (
|
||||
// KeywordSearchText 按数据库原始文本执行不区分大小写的包含匹配。
|
||||
KeywordSearchText KeywordSearchKind = "text"
|
||||
// KeywordSearchEnum 仅按页面展示的中文枚举名称匹配,不暴露内部英文编码。
|
||||
// KeywordSearchEnum 同时按页面中文名称和内部稳定编码匹配。
|
||||
KeywordSearchEnum KeywordSearchKind = "enum"
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ func useConfiguredKeywordSearch(ctx *gin.Context) bool {
|
||||
}
|
||||
|
||||
// configuredKeywordConditions 将用户关键字编译为参数化 SQL 条件。
|
||||
// 枚举字段只接受中文展示名称,普通文本字段保持原有包含匹配行为。
|
||||
// 枚举字段接受中文展示名称和内部编码,普通文本字段保持原有包含匹配行为。
|
||||
func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
||||
fields := ConfiguredKeywordSearchFields(model)
|
||||
conditions := make([]string, 0, len(fields))
|
||||
@@ -113,7 +113,8 @@ func configuredKeywordConditions(model any, keyword string) ([]string, []any) {
|
||||
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) {
|
||||
if strings.Contains(strings.ToLower(value.Label), keyword) ||
|
||||
strings.Contains(strings.ToLower(value.Value), keyword) {
|
||||
matched = append(matched, value.Value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// keywordSearchEnumModel 用于验证中文枚举别名不会退化为英文编码搜索。
|
||||
// keywordSearchEnumModel 用于验证中文枚举别名和稳定编码均可搜索。
|
||||
type keywordSearchEnumModel struct {
|
||||
RoleCode string `gorm:"column:role_code"`
|
||||
}
|
||||
@@ -48,8 +48,9 @@ func TestConfiguredKeywordConditionsMatchChineseEnumLabels(t *testing.T) {
|
||||
}
|
||||
|
||||
conditions, arguments = configuredKeywordConditions(&keywordSearchEnumModel{}, "delivery")
|
||||
if len(conditions) != 0 || len(arguments) != 0 {
|
||||
t.Fatalf("英文枚举编码不应继续可搜:conditions=%v arguments=%v", conditions, arguments)
|
||||
if !reflect.DeepEqual(conditions, []string{`"role_code" IN (?)`}) ||
|
||||
!reflect.DeepEqual(arguments, []any{"delivery"}) {
|
||||
t.Fatalf("英文枚举编码应保持可搜:conditions=%v arguments=%v", conditions, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
package delivery
|
||||
|
||||
import "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
|
||||
type ResourceContract 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"`
|
||||
}
|
||||
|
||||
func ExpectedResources() []ResourceContract {
|
||||
items := []ResourceContract{
|
||||
{"profile", "delivery_profile", "/delivery_profile", "list", "readonly"},
|
||||
{"staff", "staff_account", "/staff_account", "list", "writable"},
|
||||
{"staff", "staff_credential", "/staff_credential", "list", "writable"},
|
||||
{"user", "user_account", "/user_account", "list", "writable"},
|
||||
{"user", "user_address", "/user_address", "list", "writable"},
|
||||
{"contract", "gasorder_contract", "/gasorder_contract", "list", "managed"},
|
||||
{"contract", "gasorder_contract_product", "/gasorder_contract_product", "list", "append_only"},
|
||||
{"contract", "gasorder_contract_revision", "/gasorder_contract_revision", "list", "readonly"},
|
||||
{"contract", "product_info", "/product_info", "list", "readonly"},
|
||||
{"gasorder", "gasorder_basic", "/gasorder_basic", "list", "append_only"},
|
||||
{"finance", "wallet_basic", "/wallet_basic", "list", "readonly"},
|
||||
{"finance", "wallet_bank", "/wallet_bank", "list", "readonly"},
|
||||
{"finance", "payment_order", "/payment_order", "list", "readonly"},
|
||||
{"finance", "wallet_record", "/wallet_record", "list", "readonly"},
|
||||
{"finance", "payment_refund", "/payment_refund", "list", "readonly"},
|
||||
{"finance", "wallet_recharge", "/wallet_recharge", "list", "append_only"},
|
||||
{"finance", "wallet_apply_cash", "/wallet_apply_cash", "list", "append_only"},
|
||||
{"finance", "fin_settlement", "/fin_settlement", "list", "readonly"},
|
||||
resourceContract("profile", "delivery_profile", "readonly"),
|
||||
resourceContract("staff", "staff_account", "writable"),
|
||||
resourceContract("staff", "staff_credential", "writable"),
|
||||
resourceContract("user", "user_account", "writable"),
|
||||
resourceContract("user", "user_address", "writable"),
|
||||
resourceContract("contract", "gasorder_contract", "managed"),
|
||||
resourceContract("contract", "gasorder_contract_product", "append_only"),
|
||||
resourceContract("contract", "gasorder_contract_revision", "readonly"),
|
||||
resourceContract("contract", "product_info", "readonly"),
|
||||
resourceContract("gasorder", "gasorder_basic", "append_only"),
|
||||
resourceContract("finance", "wallet_basic", "readonly"),
|
||||
resourceContract("finance", "wallet_bank", "readonly"),
|
||||
resourceContract("finance", "payment_order", "readonly"),
|
||||
resourceContract("finance", "wallet_record", "readonly"),
|
||||
resourceContract("finance", "payment_refund", "readonly"),
|
||||
resourceContract("finance", "wallet_recharge", "append_only"),
|
||||
resourceContract("finance", "wallet_apply_cash", "append_only"),
|
||||
resourceContract("finance", "fin_settlement", "readonly"),
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// resourceContract 创建配送端标准资源契约,并附加显式搜索字段。
|
||||
func resourceContract(domain, name, mode string) ResourceContract {
|
||||
return ResourceContract{
|
||||
Domain: domain, Name: name, Path: "/" + name, PageKind: "list", Mode: mode,
|
||||
SearchFields: resourceSearchFields(name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 功能描述:验证配送端资源搜索契约与页面能力保持一致。版本:v1.0.0。
|
||||
package delivery
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestExpectedResourcesExposeSearchContract 验证有效搜索与隐藏搜索的资源边界。
|
||||
func TestExpectedResourcesExposeSearchContract(t *testing.T) {
|
||||
resources := ExpectedResources()
|
||||
byName := make(map[string]ResourceContract, len(resources))
|
||||
for _, resource := range resources {
|
||||
byName[resource.Name] = resource
|
||||
}
|
||||
credential := byName["staff_credential"].SearchFields
|
||||
if len(credential) != 2 || credential[0].Key != "credential_type" || credential[1].Key != "credential_no" {
|
||||
t.Fatalf("人员资质搜索契约不完整:%#v", credential)
|
||||
}
|
||||
for _, name := range []string{"delivery_profile", "user_address", "wallet_bank"} {
|
||||
if len(byName[name].SearchFields) != 0 {
|
||||
t.Fatalf("%s 不应显示无效搜索:%#v", name, byName[name].SearchFields)
|
||||
}
|
||||
}
|
||||
channel := byName["payment_order"].SearchFields[2]
|
||||
if channel.Kind != "enum" || len(channel.Values) != 3 {
|
||||
t.Fatalf("支付渠道中英文搜索契约不完整:%#v", channel)
|
||||
}
|
||||
}
|
||||
62
backend/api/internal/logic/delivery/resource_search.go
Normal file
62
backend/api/internal/logic/delivery/resource_search.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package delivery 定义配送点管理端公开的资源搜索契约。
|
||||
// 版本:v1.0.0
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
// deliveryResourceSearchFields 只公开页面能够解释且服务端确实执行的搜索字段。
|
||||
var deliveryResourceSearchFields = map[string][]common.KeywordSearchField{
|
||||
"staff_account": {searchText("username")},
|
||||
"staff_credential": {searchText("credential_type"), searchText("credential_no")},
|
||||
"user_account": {searchText("username")},
|
||||
"gasorder_contract": {searchText("contract_no"), searchText("title")},
|
||||
"gasorder_contract_product": {searchText("product_code"), searchText("product_type_name")},
|
||||
"gasorder_contract_revision": {searchEnum("action", searchOption("activate", "启用"), searchOption("renew", "续签"), searchOption("terminate", "终止"))},
|
||||
"product_info": {searchText("code"), searchText("name")},
|
||||
"gasorder_basic": {searchText("request_no"), searchEnum("creator_type",
|
||||
searchOption("user", "用户"), searchOption("staff", "工作人员"),
|
||||
searchOption("delivery", "配送点"), searchOption("gas", "气站"))},
|
||||
"payment_order": {searchText("payment_no"), searchText("request_no"), searchEnum("channel",
|
||||
searchOption("wechat", "微信"), searchOption("alipay", "支付宝"), searchOption("mock", "模拟支付"))},
|
||||
"wallet_record": {searchText("record_no"), searchText("request_no")},
|
||||
"payment_refund": {searchText("refund_no"), searchText("request_no")},
|
||||
"wallet_recharge": {searchText("record_no"), searchText("request_no")},
|
||||
"wallet_apply_cash": {searchText("cash_no"), searchText("request_no"), searchEnum("channel",
|
||||
searchOption("bank", "银行卡"), searchOption("alipay", "支付宝"), searchOption("wechat", "微信"))},
|
||||
"fin_settlement": {searchText("settlement_no")},
|
||||
}
|
||||
|
||||
// init 只注册平台总后台尚未注册的模型;共享模型继续复用全局策略。
|
||||
func init() {
|
||||
common.RegisterKeywordSearchPolicy(&models.GasorderContractProduct{}, deliveryResourceSearchFields["gasorder_contract_product"])
|
||||
common.RegisterKeywordSearchPolicy(&models.GasorderContractRevision{}, deliveryResourceSearchFields["gasorder_contract_revision"])
|
||||
common.RegisterKeywordSearchPolicy(&models.PaymentOrder{}, deliveryResourceSearchFields["payment_order"])
|
||||
common.RegisterKeywordSearchPolicy(&models.WalletRecord{}, deliveryResourceSearchFields["wallet_record"])
|
||||
}
|
||||
|
||||
// resourceSearchFields 返回搜索契约副本,防止调用方修改全局定义。
|
||||
func resourceSearchFields(name string) []common.KeywordSearchField {
|
||||
fields := deliveryResourceSearchFields[name]
|
||||
result := make([]common.KeywordSearchField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
copied := field
|
||||
copied.Values = append([]common.KeywordSearchValue(nil), field.Values...)
|
||||
result = append(result, copied)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchText(key string) common.KeywordSearchField {
|
||||
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchText}
|
||||
}
|
||||
|
||||
func searchEnum(key string, values ...common.KeywordSearchValue) common.KeywordSearchField {
|
||||
return common.KeywordSearchField{Key: key, Kind: common.KeywordSearchEnum, Values: values}
|
||||
}
|
||||
|
||||
func searchOption(value, label string) common.KeywordSearchValue {
|
||||
return common.KeywordSearchValue{Value: value, Label: label}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
@@ -187,17 +188,38 @@ func ArchiveStaff(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func credentialQuery(point models.DeliveryBasic) *gorm.DB {
|
||||
return common.ActiveRecords(db().Model(&models.StaffCredential{})).
|
||||
return credentialQueryWithDB(db(), point)
|
||||
}
|
||||
|
||||
// credentialQueryWithDB 固定资质所属配送点、气站和配送角色范围。
|
||||
func credentialQueryWithDB(databaseService *gorm.DB, point models.DeliveryBasic) *gorm.DB {
|
||||
return common.ActiveRecords(databaseService.Model(&models.StaffCredential{})).
|
||||
Joins("JOIN staff_account ON staff_account.id = staff_credential.staff_account_id").
|
||||
Where("staff_account.delivery_basic_id = ? AND staff_account.gas_basic_id = ? AND staff_account.role_code = ?",
|
||||
point.ID, point.GasBasicID, "delivery")
|
||||
}
|
||||
|
||||
// scopedCredentialQuery 将资质列表进一步锁定到已验证的配送人员。
|
||||
func scopedCredentialQuery(databaseService *gorm.DB, point models.DeliveryBasic, staffID uint64) *gorm.DB {
|
||||
return credentialQueryWithDB(databaseService, point).
|
||||
Where("staff_credential.staff_account_id = ?", staffID)
|
||||
}
|
||||
|
||||
func ListCredential(ctx *gin.Context) {
|
||||
point, _, ok := currentScope(ctx)
|
||||
if ok {
|
||||
listScoped(ctx, &models.StaffCredential{}, credentialQuery(point), "staff_credential.created_at desc")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
staffIdentity := strings.TrimSpace(ctx.Query("staff_account_identity"))
|
||||
if staffIdentity == "" {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
staff, found := scopedStaff(ctx, staffIdentity, point)
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
listScoped(ctx, &models.StaffCredential{}, scopedCredentialQuery(db(), point, staff.ID), "staff_credential.created_at desc")
|
||||
}
|
||||
|
||||
func GetCredential(ctx *gin.Context) {
|
||||
@@ -260,9 +282,13 @@ func UpdateCredential(ctx *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if staff.ID != existing.StaffAccountID {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if err := db().Model(&existing).Updates(map[string]any{
|
||||
"staff_account_id": staff.ID, "credential_type": request.CredentialType,
|
||||
"credential_no": request.CredentialNo, "expired_at": request.ExpiredAt,
|
||||
"credential_type": request.CredentialType, "credential_no": request.CredentialNo,
|
||||
"expired_at": request.ExpiredAt,
|
||||
}).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
|
||||
@@ -31,3 +31,24 @@ func TestDeliveryStaffQueryKeepsRoleScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedCredentialQueryKeepsOwnerAndRoleScope 验证资质列表同时限制人员和配送角色。
|
||||
func TestScopedCredentialQueryKeepsOwnerAndRoleScope(t *testing.T) {
|
||||
connection, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("创建 SQL mock 失败:%v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatalf("打开 GORM 失败:%v", err)
|
||||
}
|
||||
point := models.DeliveryBasic{Entity: models.Entity{ID: 22}, GasBasicID: 11}
|
||||
statement := scopedCredentialQuery(databaseService, point, 33).
|
||||
Find(&[]models.StaffCredential{}).Statement.SQL.String()
|
||||
for _, required := range []string{"delivery_basic_id", "gas_basic_id", "role_code", "staff_account_id"} {
|
||||
if !strings.Contains(statement, required) {
|
||||
t.Fatalf("配送人员资质范围缺少 %s:%s", required, statement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ var resourceSearchDefinitions = []resourceSearchDefinition{
|
||||
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("staff_credential", &models.StaffCredential{}, text("credential_type"), text("credential_no")),
|
||||
searchDefinition("user_account", &models.UserAccount{}, text("username")),
|
||||
searchDefinition("producer_account", &models.ProducerAccount{}, text("name")),
|
||||
searchDefinition("product_type", &models.ProductType{}, text("code"), text("name")),
|
||||
@@ -36,8 +36,8 @@ var resourceSearchDefinitions = []resourceSearchDefinition{
|
||||
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")),
|
||||
searchDefinition("payment_refund", &models.PaymentRefund{}, text("refund_no"), text("request_no")),
|
||||
searchDefinition("wallet_apply_cash", &models.WalletApplyCash{}, text("cash_no"), text("request_no"), enum("channel", value("bank", "银行卡"), value("alipay", "支付宝"), value("wechat", "微信"))),
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
|
||||
deliverylogic "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/delivery"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -16,6 +17,7 @@ func RegisterDelivery(serviceKey string, engine *gin.Engine) {
|
||||
protected := engine.Group(basePath)
|
||||
protected.Use(sdkmiddleware.JwtAuth(true))
|
||||
protected.Use(deliverylogic.RequireDeliveryAdmin())
|
||||
protected.Use(common.EnableConfiguredKeywordSearch())
|
||||
protected.GET("/auth/profile", deliverylogic.CurrentProfile)
|
||||
protected.PUT("/auth/password", deliverylogic.ChangePassword)
|
||||
protected.GET("/delivery_menu", deliverylogic.ListMenu)
|
||||
|
||||
Reference in New Issue
Block a user