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" "gorm.io/gorm" ) // 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 := impl.DBService.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": 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, response) } func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { values, err := prepareResourceValues(ctx, 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 } infra.Response.Success(ctx, resourceResponse(data.Interface())) } 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, 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 || 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{}, "smart_cylinder_valve_id": &models.DevSmartCylinderValve{}, "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{}, "report_id": &models.Report{}, "wallet_id": &models.Wallet{}, } 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 } // DisposeSafetyEvent atomically updates an event and appends its operator-owned // action record. Disposal records deliberately have no update or delete route. func DisposeSafetyEvent(ctx *gin.Context) { claims, err := middleware.ParseAuth(ctx) if err != nil { infra.Response.Error(ctx, err) return } var request struct { Action string `json:"action" binding:"required,max=64"` Reason string `json:"reason" binding:"max=2000"` Status string `json:"status" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } if request.Status == "" { request.Status = "disposed" } var disposal models.SafEventDisposal err = impl.DBService.Transaction(func(transaction *gorm.DB) error { var event models.SafEvent if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&event).Error; err != nil { return err } if result := transaction.Model(&models.SafEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil { return result.Error } else if result.RowsAffected == 0 { return gorm.ErrRecordNotFound } disposal = models.SafEventDisposal{ Entity: newEntity("enabled"), SafEventIdentity: event.Identity, Action: request.Action, Reason: request.Reason, OperatorIdentity: claims.Identity, } return transaction.Create(&disposal).Error }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return } infra.Response.Error(ctx, err) return } infra.Response.Success(ctx, resourceResponse(disposal)) } // 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 } precise := false if claims, err := middleware.ParseAuth(ctx); err == nil { precise = claims.Extend["location_scope"] == "precise" } if !precise { 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]) }