package platform import ( "bytes" "encoding/json" "errors" "io" "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) } } // FinSettlementHandlers accepts a public subject_identity and derives the // polymorphic storage key from its declared subject_type. func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"} return func(ctx *gin.Context) { listResource(ctx, &models.FinSettlement{}) }, func(ctx *gin.Context) { if err := rewriteSettlementSubject(ctx); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } createResource(ctx, &models.FinSettlement{}, fields, nil) }, func(ctx *gin.Context) { getResource(ctx, &models.FinSettlement{}) }, func(ctx *gin.Context) { if err := rewriteSettlementSubject(ctx); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } updateResource(ctx, &models.FinSettlement{}, fields, nil) } } func rewriteSettlementSubject(ctx *gin.Context) error { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil { return err } subjectType, _ := input["subject_type"].(string) identity, _ := input["subject_identity"].(string) var model any switch subjectType { case "gas", "gas_basic": model = &models.GasBasic{} case "delivery", "delivery_basic": model = &models.DeliveryBasic{} case "staff", "staff_account": model = &models.StaffAccount{} default: return errors.New("invalid settlement subject") } id, err := resolveIdentityID(model, identity, true) if err != nil { return err } delete(input, "subject_identity") input["subject_id"] = id encoded, err := json.Marshal(input) if err != nil { return err } ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) return nil } 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, } 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 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{}, "delivery_task_id": &models.DeliveryTask{}, "delivery_track_id": &models.DeliveryTrack{}, "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 } // GetEcOrder returns the order together with its immutable item snapshots. func GetEcOrder(ctx *gin.Context) { var order models.EcOrder if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { respondRecordError(ctx, err) return } var items []models.EcOrderItem if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil { infra.Response.Error(ctx, err) return } response, err := publicResourceResponse(gin.H{"order": order, "items": items}) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, response) } // GetDeliveryTrack returns time-ordered points. Precise coordinates are only // exposed to tokens explicitly granted the location_scope=precise claim. func GetDeliveryTrack(ctx *gin.Context) { var track models.DeliveryTrack if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil { respondRecordError(ctx, err) return } var points []models.DeliveryTrackPoint if err := impl.DBService.Where("delivery_track_id = ?", track.ID).Order("occurred_at asc").Find(&points).Error; err != nil { infra.Response.Error(ctx, err) return } if !hasPreciseLocationScope(ctx) { for index := range points { points[index].Longitude = "" points[index].Latitude = "" } } response, err := publicResourceResponse(gin.H{"track": track, "points": points}) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, response) } type ecCategoryView struct { Identity string `json:"identity"` ParentIdentity string `json:"parent_identity,omitempty"` Name string `json:"name"` SortNo int `json:"sort_no"` Status string `json:"status"` } func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) { parents := make(map[uint64]string) for _, item := range list { if item.ParentID != 0 { parents[item.ParentID] = "" } } if len(parents) > 0 { var rows []struct { ID uint64 Identity string } ids := make([]uint64, 0, len(parents)) for id := range parents { ids = append(ids, id) } if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil { return nil, err } for _, row := range rows { parents[row.ID] = row.Identity } } views := make([]ecCategoryView, 0, len(list)) for _, item := range list { views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status}) } return views, nil } func ListEcCategory(ctx *gin.Context) { page, size := pageSize(ctx) var list []models.EcCategory var total int64 if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return } if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } views, err := ecCategoryViews(list) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, gin.H{"total": total, "list": views}) } func GetEcCategory(ctx *gin.Context) { var category models.EcCategory if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { respondRecordError(ctx, err) return } views, err := ecCategoryViews([]models.EcCategory{category}) if err != nil { infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, views[0]) }