refactor platform logic and add gasorder domain
This commit is contained in:
222
backend/api/internal/logic/common/base.go
Normal file
222
backend/api/internal/logic/common/base.go
Normal file
@@ -0,0 +1,222 @@
|
||||
// Package platform 提供平台总后台的同步 HTTP 业务逻辑。
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/bsm-sdk/core/utils"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FilterFields keeps only explicitly allowed persistence fields.
|
||||
func FilterFields(values map[string]any, allowedFields []string) gin.H {
|
||||
allowed := make(map[string]struct{}, len(allowedFields))
|
||||
for _, field := range allowedFields {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
filtered := make(gin.H, len(allowed))
|
||||
for field, value := range values {
|
||||
if _, ok := allowed[field]; ok {
|
||||
filtered[field] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// UpdateRecordStatus 更新主表状态,停用和归档均保留历史记录。
|
||||
func UpdateRecordStatus(ctx *gin.Context, model any) {
|
||||
var request struct {
|
||||
Status string `json:"status" binding:"required,max=32"`
|
||||
}
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
UpdateAllowedByIdentity(ctx, model, gin.H{"status": request.Status}, []string{"status"})
|
||||
}
|
||||
|
||||
// ArchiveRecord 通过 archived 状态实现逻辑删除,不物理删除主数据。
|
||||
func ArchiveRecord(ctx *gin.Context, model any) {
|
||||
UpdateAllowedByIdentity(ctx, model, gin.H{"status": "archived"}, []string{"status"})
|
||||
}
|
||||
|
||||
func NewEntity(status string) models.Entity {
|
||||
return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1}
|
||||
}
|
||||
|
||||
func ListPage[T any](ctx *gin.Context) {
|
||||
page, size := PageSize(ctx)
|
||||
var list []T
|
||||
var total int64
|
||||
model := new(T)
|
||||
databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
if err := databaseQuery.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := PublicResourceResponse(list)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)})
|
||||
}
|
||||
|
||||
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,
|
||||
"online_status": true, "rule_code": true, "action": true,
|
||||
"event_code": true, "title": true, "result": true,
|
||||
"product_code": true, "value": true, "order_no": true,
|
||||
"channel": true, "settlement_no": true, "subject_type": true,
|
||||
"content_type": true, "publish_status": true, "template_code": true,
|
||||
"ticket_no": true, "category": true, "priority": true,
|
||||
"platform_role_code": true, "data_scope": true, "menu_code": true,
|
||||
"path": true, "resource_type": true,
|
||||
"owner_type": true, "owner_identity": true, "payment_no": true,
|
||||
"record_no": true, "request_no": true, "refund_no": true,
|
||||
"cash_no": true, "trade_no": true, "trade_type": true,
|
||||
"pay_channel": true, "payment_type": true,
|
||||
"contract_no": true, "creator_type": true, "from_status": true,
|
||||
"to_status": true, "confirm_type": true, "product_type_name": true,
|
||||
}
|
||||
|
||||
func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
|
||||
keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword")))
|
||||
if keyword == "" {
|
||||
return query
|
||||
}
|
||||
columns := keywordColumns(model)
|
||||
if len(columns) == 0 {
|
||||
return query
|
||||
}
|
||||
conditions := make([]string, 0, len(columns))
|
||||
arguments := make([]any, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
conditions = append(conditions, `LOWER("`+column+`") LIKE ?`)
|
||||
arguments = append(arguments, "%"+keyword+"%")
|
||||
}
|
||||
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
|
||||
}
|
||||
|
||||
func keywordColumns(model any) []string {
|
||||
modelType := reflect.TypeOf(model)
|
||||
for modelType.Kind() == reflect.Pointer {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
columns := make([]string, 0)
|
||||
for index := 0; index < modelType.NumField(); index++ {
|
||||
field := modelType.Field(index)
|
||||
if field.Anonymous || field.Type.Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
column := gormColumn(field.Tag.Get("gorm"))
|
||||
if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) {
|
||||
columns = append(columns, column)
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
func isSensitiveKeywordColumn(model any, column string) bool {
|
||||
switch model.(type) {
|
||||
case *models.UserAccount, *models.StaffAccount:
|
||||
return column == "name"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func gormColumn(tag string) string {
|
||||
for _, part := range strings.Split(tag, ";") {
|
||||
if strings.HasPrefix(part, "column:") {
|
||||
return strings.TrimPrefix(part, "column:")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetByIdentity[T any](ctx *gin.Context) {
|
||||
var data T
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {
|
||||
RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := PublicResourceResponse(data)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, new(T), response))
|
||||
}
|
||||
|
||||
func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
|
||||
values = FilterFields(values, allowedFields)
|
||||
if len(values) == 0 {
|
||||
infra.Response.Success(ctx, gin.H{"updated": false})
|
||||
return
|
||||
}
|
||||
|
||||
result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values)
|
||||
if result.Error != nil {
|
||||
infra.Response.Error(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func UpdateByIdentity(ctx *gin.Context, model any, values map[string]any) {
|
||||
result := impl.DBService.Model(model).Where("identity = ?", ctx.Param("identity")).Updates(values)
|
||||
if result.Error != nil {
|
||||
infra.Response.Error(ctx, result.Error)
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||||
}
|
||||
|
||||
func RespondRecordError(ctx *gin.Context, err error) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
infra.Response.Error(ctx, err)
|
||||
}
|
||||
|
||||
func PageSize(ctx *gin.Context) (int, int) {
|
||||
page := utils.String2Int(ctx.DefaultQuery("page", "1"))
|
||||
size := utils.String2Int(ctx.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
|
||||
func MaskPhone(phone string) string {
|
||||
if len(phone) < 7 {
|
||||
return "***"
|
||||
}
|
||||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||||
}
|
||||
501
backend/api/internal/logic/common/resource.go
Normal file
501
backend/api/internal/logic/common/resource.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/bsm-sdk/core/middleware"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ResourceRelation accepts a stable external identity while retaining the
|
||||
// relation's database ID as an internal persistence detail.
|
||||
type ResourceRelation struct {
|
||||
Input string
|
||||
Column string
|
||||
Model any
|
||||
Required bool
|
||||
}
|
||||
|
||||
// ResourceHandlers supplies the common identity-based CRUD boundary used by
|
||||
// platform resources whose writable fields are explicitly declared by routes.
|
||||
func ResourceHandlers(model any, createFields, updateFields []string, relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||
return func(ctx *gin.Context) { ListResource(ctx, model) },
|
||||
func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) },
|
||||
func(ctx *gin.Context) { GetResource(ctx, model) },
|
||||
func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) }
|
||||
}
|
||||
|
||||
func ListResource(ctx *gin.Context, model any) {
|
||||
page, size := PageSize(ctx)
|
||||
list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem()))
|
||||
var total int64
|
||||
query := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model)
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(list.Interface()).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := PublicResourceResponse(list.Elem().Interface())
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)})
|
||||
}
|
||||
|
||||
func GetResource(ctx *gin.Context, model any) {
|
||||
data := reflect.New(reflect.TypeOf(model).Elem())
|
||||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil {
|
||||
RespondRecordError(ctx, err)
|
||||
return
|
||||
}
|
||||
response, err := PublicResourceResponse(data.Interface())
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, model, response))
|
||||
}
|
||||
|
||||
func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
values, err := PrepareResourceValues(ctx, model, allowedFields, relations)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
encoded, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data := reflect.New(reflect.TypeOf(model).Elem())
|
||||
if err := json.Unmarshal(encoded, data.Interface()); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
data.Elem().FieldByName("Entity").Set(reflect.ValueOf(NewEntity("draft")))
|
||||
if err := impl.DBService.Create(data.Interface()).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
RespondCreatedResource(ctx, data.Interface())
|
||||
}
|
||||
|
||||
func RespondCreatedResource(ctx *gin.Context, value any) {
|
||||
response, err := PublicResourceResponse(value)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, maskCreatedSensitiveFields(response))
|
||||
}
|
||||
|
||||
func maskCreatedSensitiveFields(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
safe := make(map[string]any)
|
||||
for key, item := range data {
|
||||
if isCreatedResponseField(key) {
|
||||
safe[key] = maskCreatedSensitiveFields(item)
|
||||
}
|
||||
}
|
||||
return safe
|
||||
case []any:
|
||||
safe := make([]any, len(data))
|
||||
for index, item := range data {
|
||||
safe[index] = maskCreatedSensitiveFields(item)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isCreatedResponseField(key string) bool {
|
||||
switch key {
|
||||
case "identity", "status", "version", "created_at", "updated_at":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(key, "_identity")
|
||||
}
|
||||
}
|
||||
|
||||
func ProtectPreciseLocation(ctx *gin.Context, model, value any) any {
|
||||
maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) ||
|
||||
reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{})
|
||||
maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatfromAccount{})
|
||||
ProtectPublicFields(value, maskPersonalName, maskDisplayName, HasPreciseLocationScope(ctx))
|
||||
return value
|
||||
}
|
||||
|
||||
var sensitiveResponseFields = map[string]bool{
|
||||
"avatar": true, "address": true, "credential_no": true,
|
||||
"evidence_uri": true, "evidence_url": true, "file_uri": true,
|
||||
"attachment_uri": true, "attachment_url": true,
|
||||
"certificate_uri": true, "certificate_url": true,
|
||||
"credential_uri": true, "credential_url": true,
|
||||
"proof_uri": true,
|
||||
}
|
||||
|
||||
func ProtectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
if phone, ok := data["phone"].(string); ok && phone != "" {
|
||||
data["phone_masked"] = MaskPhone(phone)
|
||||
}
|
||||
delete(data, "phone")
|
||||
for _, key := range []string{"contact_phone", "recipient_phone"} {
|
||||
if phone, ok := data[key].(string); ok && phone != "" {
|
||||
data[key+"_masked"] = MaskPhone(phone)
|
||||
}
|
||||
delete(data, key)
|
||||
}
|
||||
for _, key := range []string{"contact_name", "recipient_name"} {
|
||||
if name, ok := data[key].(string); ok && name != "" {
|
||||
data[key+"_masked"] = MaskPersonalNameValue(name)
|
||||
}
|
||||
delete(data, key)
|
||||
}
|
||||
for key := range sensitiveResponseFields {
|
||||
delete(data, key)
|
||||
}
|
||||
if maskPersonalName {
|
||||
if name, ok := data["name"].(string); ok && name != "" {
|
||||
data["name_masked"] = MaskPersonalNameValue(name)
|
||||
}
|
||||
delete(data, "name")
|
||||
if name, ok := data["real_name"].(string); ok && name != "" {
|
||||
data["real_name_masked"] = MaskPersonalNameValue(name)
|
||||
}
|
||||
delete(data, "real_name")
|
||||
}
|
||||
if maskDisplayName {
|
||||
if name, ok := data["display_name"].(string); ok && name != "" {
|
||||
data["display_name_masked"] = MaskPersonalNameValue(name)
|
||||
}
|
||||
delete(data, "display_name")
|
||||
}
|
||||
if !retainCoordinates {
|
||||
delete(data, "longitude")
|
||||
delete(data, "latitude")
|
||||
}
|
||||
for _, item := range data {
|
||||
ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range data {
|
||||
ProtectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MaskPersonalNameValue(name string) string {
|
||||
runes := []rune(name)
|
||||
if len(runes) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(runes) == 1 {
|
||||
return "*"
|
||||
}
|
||||
return string(runes[0]) + strings.Repeat("*", len(runes)-1)
|
||||
}
|
||||
|
||||
func HasPreciseLocationScope(ctx *gin.Context) bool {
|
||||
claims, err := middleware.ParseAuth(ctx)
|
||||
return err == nil && claims.Extend["location_scope"] == "precise"
|
||||
}
|
||||
|
||||
func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
values, err := ResolveResourceRelations(input, allowedFields, relations, false)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
if len(values) == 0 {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
UpdateAllowedByIdentity(ctx, model, values, append(allowedFields, relationColumns(relations)...))
|
||||
}
|
||||
|
||||
func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) (map[string]any, error) {
|
||||
var input map[string]any
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil || len(input) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
values, err := ResolveResourceRelations(input, allowedFields, relations, true)
|
||||
if err != nil || len(values) == 0 {
|
||||
return nil, errors.New("invalid resource payload")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) {
|
||||
values := FilterFields(input, allowedFields)
|
||||
for _, relation := range relations {
|
||||
raw, exists := input[relation.Input]
|
||||
if !exists {
|
||||
if requireRelations && relation.Required {
|
||||
return nil, errors.New("missing required relation")
|
||||
}
|
||||
continue
|
||||
}
|
||||
identity, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid relation identity")
|
||||
}
|
||||
if strings.TrimSpace(identity) == "" && !relation.Required {
|
||||
values[relation.Column] = uint64(0)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(identity) == "" {
|
||||
return nil, errors.New("invalid relation identity")
|
||||
}
|
||||
id, err := ResolveIdentityID(relation.Model, identity, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[relation.Column] = id
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ResolveIdentityID is the only boundary that converts a public identity to a
|
||||
// persistence-only numeric key. Callers must never bind a client supplied ID.
|
||||
func ResolveIdentityID(model any, identity string, required bool) (uint64, error) {
|
||||
identity = strings.TrimSpace(identity)
|
||||
if identity == "" {
|
||||
if required {
|
||||
return 0, errors.New("missing required relation")
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
var related struct{ ID uint64 }
|
||||
if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return related.ID, nil
|
||||
}
|
||||
|
||||
func relationColumns(relations []ResourceRelation) []string {
|
||||
columns := make([]string, 0, len(relations))
|
||||
for _, relation := range relations {
|
||||
columns = append(columns, relation.Column)
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// ResourceResponse strips database surrogate IDs from API data. Business
|
||||
// identities are the only public relation keys accepted or returned.
|
||||
func ResourceResponse(value any) any {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
return value
|
||||
}
|
||||
return stripInternalIDs(decoded)
|
||||
}
|
||||
|
||||
// PublicResourceResponse additionally resolves persisted relation keys into
|
||||
// their public identities. It is used by list/detail endpoints so an edit form
|
||||
// can round-trip the relation without ever receiving a surrogate database ID.
|
||||
func PublicResourceResponse(value any) (any, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projectRelationIdentities(decoded)
|
||||
}
|
||||
|
||||
var relationIdentityModels = map[string]any{
|
||||
"gas_basic_id": &models.GasBasic{},
|
||||
"gas_station_id": &models.GasBasic{},
|
||||
"delivery_basic_id": &models.DeliveryBasic{},
|
||||
"delivery_point_id": &models.DeliveryBasic{},
|
||||
"user_account_id": &models.UserAccount{},
|
||||
"staff_account_id": &models.StaffAccount{},
|
||||
"product_type_id": &models.ProductType{},
|
||||
"product_info_id": &models.ProductInfo{},
|
||||
"warehouse_id": &models.ProductWarehouse{},
|
||||
"ec_category_id": &models.EcCategory{},
|
||||
"ec_product_id": &models.EcProduct{},
|
||||
"ec_order_id": &models.EcOrder{},
|
||||
"gasorder_contract_id": &models.GasorderContract{},
|
||||
"gasorder_contract_product_id": &models.GasorderContractProduct{},
|
||||
"gasorder_basic_id": &models.GasorderBasic{},
|
||||
"gasorder_track_id": &models.GasorderTrack{},
|
||||
"platform_role_id": &models.PlatformRole{},
|
||||
"platform_menu_id": &models.PlatformMenu{},
|
||||
"wallet_basic_id": &models.WalletBasic{},
|
||||
"wallet_payment_id": &models.WalletPayment{},
|
||||
"wallet_bank_id": &models.WalletBank{},
|
||||
"related_record_id": &models.WalletRecord{},
|
||||
}
|
||||
|
||||
var relationIdentityKeys = map[string]string{
|
||||
"gas_station_id": "gas_basic_identity",
|
||||
"delivery_point_id": "delivery_basic_identity",
|
||||
}
|
||||
|
||||
type relationIdentityReference struct {
|
||||
target map[string]any
|
||||
identityKey string
|
||||
id uint64
|
||||
}
|
||||
|
||||
type relationIdentityGroup struct {
|
||||
model any
|
||||
ids []uint64
|
||||
seen map[uint64]struct{}
|
||||
references []relationIdentityReference
|
||||
}
|
||||
|
||||
type relationIdentityRecord struct {
|
||||
ID uint64
|
||||
Identity string
|
||||
}
|
||||
|
||||
func projectRelationIdentities(value any) (any, error) {
|
||||
groups := map[string]*relationIdentityGroup{}
|
||||
collectRelationIdentityReferences(value, groups)
|
||||
groupKeys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
groupKeys = append(groupKeys, key)
|
||||
}
|
||||
sort.Strings(groupKeys)
|
||||
for _, key := range groupKeys {
|
||||
group := groups[key]
|
||||
var rows []relationIdentityRecord
|
||||
if err := impl.DBService.Model(group.model).Select("id", "identity").Where("id IN ?", group.ids).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identities := make(map[uint64]string, len(rows))
|
||||
for _, row := range rows {
|
||||
identities[row.ID] = row.Identity
|
||||
}
|
||||
for _, reference := range group.references {
|
||||
identity, found := identities[reference.id]
|
||||
if !found {
|
||||
return nil, errors.New("related identity not found")
|
||||
}
|
||||
reference.target[reference.identityKey] = identity
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup) {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range data {
|
||||
if key == "id" {
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(key, "_id") {
|
||||
identityKey := strings.TrimSuffix(key, "_id") + "_identity"
|
||||
if alias := relationIdentityKeys[key]; alias != "" {
|
||||
identityKey = alias
|
||||
}
|
||||
model := relationIdentityModels[key]
|
||||
if key == "subject_id" {
|
||||
model = settlementSubjectModel(data["subject_type"])
|
||||
identityKey = "subject_identity"
|
||||
}
|
||||
if model != nil {
|
||||
if id, ok := responseRelationID(item); ok && id != 0 {
|
||||
key := reflect.TypeOf(model).String()
|
||||
group := groups[key]
|
||||
if group == nil {
|
||||
group = &relationIdentityGroup{model: model, seen: map[uint64]struct{}{}}
|
||||
groups[key] = group
|
||||
}
|
||||
if _, found := group.seen[id]; !found {
|
||||
group.ids = append(group.ids, id)
|
||||
group.seen[id] = struct{}{}
|
||||
}
|
||||
group.references = append(group.references, relationIdentityReference{target: data, identityKey: identityKey, id: id})
|
||||
} else {
|
||||
data[identityKey] = ""
|
||||
}
|
||||
}
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
collectRelationIdentityReferences(item, groups)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range data {
|
||||
collectRelationIdentityReferences(item, groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func responseRelationID(value any) (uint64, bool) {
|
||||
var id uint64
|
||||
switch raw := value.(type) {
|
||||
case float64:
|
||||
id = uint64(raw)
|
||||
case uint64:
|
||||
id = raw
|
||||
case int:
|
||||
id = uint64(raw)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func settlementSubjectModel(value any) any {
|
||||
subjectType, _ := value.(string)
|
||||
switch subjectType {
|
||||
case "gas", "gas_basic":
|
||||
return &models.GasBasic{}
|
||||
case "delivery", "delivery_basic":
|
||||
return &models.DeliveryBasic{}
|
||||
case "staff", "staff_account":
|
||||
return &models.StaffAccount{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stripInternalIDs(value any) any {
|
||||
switch data := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range data {
|
||||
if key == "id" || strings.HasSuffix(key, "_id") {
|
||||
delete(data, key)
|
||||
continue
|
||||
}
|
||||
data[key] = stripInternalIDs(item)
|
||||
}
|
||||
case []any:
|
||||
for index := range data {
|
||||
data[index] = stripInternalIDs(data[index])
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
58
backend/api/internal/logic/common/resource_test.go
Normal file
58
backend/api/internal/logic/common/resource_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) {
|
||||
got := FilterFields(map[string]any{"name": "n", "password_hash": "x"}, []string{"name"})
|
||||
if len(got) != 1 || got["name"] != "n" {
|
||||
t.Fatalf("unexpected filtered fields: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) {
|
||||
got := ResourceResponse(map[string]any{
|
||||
"id": uint64(1), "identity": "root",
|
||||
"child": map[string]any{"gasorder_basic_id": uint64(2), "identity": "child"},
|
||||
}).(map[string]any)
|
||||
if _, exists := got["id"]; exists {
|
||||
t.Fatal("root database ID was exposed")
|
||||
}
|
||||
child := got["child"].(map[string]any)
|
||||
if _, exists := child["gasorder_basic_id"]; exists {
|
||||
t.Fatal("relation database ID was exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicFieldProtectionMasksGasorderContacts(t *testing.T) {
|
||||
value := map[string]any{"contact_name": "张三", "contact_phone": "13800138000"}
|
||||
ProtectPublicFields(value, false, false, false)
|
||||
if _, exists := value["contact_name"]; exists {
|
||||
t.Fatal("contact name remains public")
|
||||
}
|
||||
if _, exists := value["contact_phone"]; exists {
|
||||
t.Fatal("contact phone remains public")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceRelationOptionalEmptyIdentityClearsRelation(t *testing.T) {
|
||||
values, err := ResolveResourceRelations(
|
||||
map[string]any{"warehouse_identity": ""},
|
||||
nil,
|
||||
[]ResourceRelation{{Input: "warehouse_identity", Column: "warehouse_id", Model: &models.ProductWarehouse{}}},
|
||||
false,
|
||||
)
|
||||
if err != nil || values["warehouse_id"] != uint64(0) {
|
||||
t.Fatalf("optional relation clear = (%#v, %v)", values, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) {
|
||||
if http.MethodGet == "" || http.MethodPost == "" {
|
||||
t.Fatal("standard HTTP methods unavailable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user