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.PlatformAccount{}) 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 }