diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index fca350f..e26206f 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -89,6 +89,7 @@ func ExpectedResources() []ResourceContract { {Domain: "ec", Name: "ec_product_image", Mode: Writable, PageKind: "list"}, {Domain: "ec", Name: "ec_cart", Mode: Writable, PageKind: "list"}, {Domain: "ec", Name: "ec_order", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_order_item", Mode: Writable, PageKind: "list"}, {Domain: "ec", Name: "ec_review", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"}, diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 0457c85..bdd5596 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -25,6 +25,7 @@ import ( func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list") + assertContract(t, ExpectedResources(), "ec", "ec_order_item", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") } @@ -183,6 +184,49 @@ func TestGetEcOrderReturnsOrderItems(t *testing.T) { assertMockExpectations(t, mock) } +func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalidPayloads(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). + WithArgs("order-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "ec_product" WHERE identity = $1 ORDER BY "ec_product"."id" LIMIT $2`)). + WithArgs("product-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) + + ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(`{"ec_order_identity":"order-a","ec_product_identity":"product-a","quantity":2}`)) + values, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{ + {Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}, + {Input: "ec_product_identity", Column: "ec_product_id", Model: &models.EcProduct{}, Required: true}, + }) + if err != nil { + t.Fatal(err) + } + if values["ec_order_id"] != uint64(1) || values["ec_product_id"] != uint64(2) || values["quantity"] != float64(2) { + t.Fatalf("unexpected resolved values: %#v", values) + } + assertMockExpectations(t, mock) + + for _, body := range []string{`{}`, `{"ec_order_id":1}`, `{"quantity":2}`} { + ctx, _ := updateContext(http.MethodPost, "/ec/ec_order_item", "", []byte(body)) + if _, err := prepareResourceValues(ctx, []string{"quantity"}, []ResourceRelation{{Input: "ec_order_identity", Column: "ec_order_id", Model: &models.EcOrder{}, Required: true}}); err == nil { + t.Fatalf("payload %s was accepted", body) + } + } +} + +func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { + response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}}) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{`"id"`, `"ec_order_id"`, `"ec_product_id"`, `"delivery_task_id"`} { + if strings.Contains(string(encoded), forbidden) { + t.Fatalf("response exposed internal relation key %s: %s", forbidden, encoded) + } + } +} + func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) now := time.Now().UTC() diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index ab0f7cc..b4152d4 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "reflect" + "strings" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -14,13 +15,22 @@ import ( "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) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { +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) }, + func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) }, func(ctx *gin.Context) { getResource(ctx, model) }, - func(ctx *gin.Context) { updateResource(ctx, model, updateFields) } + func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) } } func listResource(ctx *gin.Context, model any) { @@ -36,7 +46,7 @@ func listResource(ctx *gin.Context, model any) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": list.Elem().Interface()}) + infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())}) } func getResource(ctx *gin.Context, model any) { @@ -45,16 +55,15 @@ func getResource(ctx *gin.Context, model any) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, data.Interface()) + infra.Response.Success(ctx, resourceResponse(data.Interface())) } -func createResource(ctx *gin.Context, model any, allowedFields []string) { - var input map[string]any - if err := ctx.ShouldBindJSON(&input); err != nil { +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 } - values := filterFields(input, allowedFields) encoded, err := json.Marshal(values) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -70,16 +79,100 @@ func createResource(ctx *gin.Context, model any, allowedFields []string) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, data.Interface()) + infra.Response.Success(ctx, resourceResponse(data.Interface())) } -func updateResource(ctx *gin.Context, model any, allowedFields []string) { +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 } - updateAllowedByIdentity(ctx, model, input, allowedFields) + 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") + } + var related struct{ ID uint64 } + if err := impl.DBService.Model(relation.Model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil { + return nil, err + } + values[relation.Column] = related.ID + } + return values, 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) +} + +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 @@ -130,7 +223,7 @@ func DisposeSafetyEvent(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, disposal) + infra.Response.Success(ctx, resourceResponse(disposal)) } // GetEcOrder returns the order together with its immutable item snapshots. @@ -145,7 +238,7 @@ func GetEcOrder(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"order": order, "items": items}) + infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items})) } // GetDeliveryTrack returns time-ordered points. Precise coordinates are only @@ -171,5 +264,5 @@ func GetDeliveryTrack(ctx *gin.Context) { points[index].Latitude = "" } } - infra.Response.Success(ctx, gin.H{"track": track, "points": points}) + infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points})) } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 76babf9..89a5baf 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -40,15 +40,17 @@ func registerGasRoute(group *gin.RouterGroup) { func registerDeliveryRoute(group *gin.RouterGroup) { registerWritableResource(group, "/delivery/delivery_basic", platform.ListDeliveryBasic, platform.CreateDeliveryBasic, platform.GetDeliveryBasic, platform.UpdateDeliveryBasic, &models.DeliveryBasic{}) registerWritableResource(group, "/delivery/delivery_account", platform.ListDeliveryAccount, platform.CreateDeliveryAccount, platform.GetDeliveryAccount, platform.UpdateDeliveryAccount, &models.DeliveryAccount{}) - registerRestrictedWritableResource(group, "/delivery/delivery_task", &models.DeliveryTask{}, []string{"ec_order_id", "staff_account_id", "delivery_point_id"}) - list, create, _, update := platform.ResourceHandlers(&models.DeliveryTrack{}, []string{"delivery_task_id", "started_at", "completed_at"}, []string{"delivery_task_id", "started_at", "completed_at"}) + registerRestrictedWritableResource(group, "/delivery/delivery_task", &models.DeliveryTask{}, nil, + requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), optionalRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{}), requiredRelation("delivery_basic_identity", "delivery_point_id", &models.DeliveryBasic{})) + trackRelations := []platform.ResourceRelation{requiredRelation("delivery_task_identity", "delivery_task_id", &models.DeliveryTask{})} + list, create, _, update := platform.ResourceHandlers(&models.DeliveryTrack{}, []string{"started_at", "completed_at"}, []string{"started_at", "completed_at"}, trackRelations...) registerWritableResource(group, "/delivery/delivery_track", list, create, platform.GetDeliveryTrack, update, &models.DeliveryTrack{}) - registerRestrictedWritableResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}, []string{"delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}) + registerRestrictedWritableResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}, []string{"point_type", "occurred_at", "longitude", "latitude"}, requiredRelation("delivery_track_identity", "delivery_track_id", &models.DeliveryTrack{})) } func registerDeviceRoute(group *gin.RouterGroup) { registerRestrictedWritableResource(group, "/device/dev_smart_cylinder_valve", &models.DevSmartCylinderValve{}, []string{"device_no", "model", "online_status", "owner_identity"}) - registerRestrictedWritableResource(group, "/device/dev_device_binding", &models.DevDeviceBinding{}, []string{"smart_cylinder_valve_id", "user_account_id", "effective_at", "expired_at"}) + registerRestrictedWritableResource(group, "/device/dev_device_binding", &models.DevDeviceBinding{}, []string{"effective_at", "expired_at"}, requiredRelation("smart_cylinder_valve_identity", "smart_cylinder_valve_id", &models.DevSmartCylinderValve{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) list, _, get, _ := platform.ResourceHandlers(&models.DevTelemetry{}, nil, nil) telemetry := group.Group("/device/dev_telemetry") telemetry.GET("", list) @@ -58,19 +60,21 @@ func registerDeviceRoute(group *gin.RouterGroup) { func registerSafetyRoute(group *gin.RouterGroup) { registerRestrictedWritableResource(group, "/safety/saf_rule", &models.SafRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"}) registerRestrictedWritableResource(group, "/safety/saf_event", &models.SafEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}) - registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"user_account_id", "staff_account_id", "result", "evidence_uri"}) + registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{})) group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent) } func registerCommerceRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/ec/ec_category", &models.EcCategory{}, []string{"parent_id", "name", "sort_no"}) - registerRestrictedWritableResource(group, "/ec/ec_product", &models.EcProduct{}, []string{"ec_category_id", "product_code", "name", "price_amount", "stock_quantity"}) - registerRestrictedWritableResource(group, "/ec/ec_product_attribute", &models.EcProductAttribute{}, []string{"ec_product_id", "name", "value", "sort_no"}) - registerRestrictedWritableResource(group, "/ec/ec_product_image", &models.EcProductImage{}, []string{"ec_product_id", "image_uri", "sort_no", "is_cover"}) - registerRestrictedWritableResource(group, "/ec/ec_cart", &models.EcCart{}, []string{"user_account_id", "ec_product_id", "quantity", "selected"}) - list, create, _, update := platform.ResourceHandlers(&models.EcOrder{}, []string{"order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}, []string{"gas_station_id", "delivery_point_id", "total_amount"}) + registerRestrictedWritableResource(group, "/ec/ec_category", &models.EcCategory{}, []string{"name", "sort_no"}, optionalRelation("parent_identity", "parent_id", &models.EcCategory{})) + registerRestrictedWritableResource(group, "/ec/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{})) + registerRestrictedWritableResource(group, "/ec/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec/ec_cart", &models.EcCart{}, []string{"quantity", "selected"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + orderRelations := []platform.ResourceRelation{requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), optionalRelation("gas_basic_identity", "gas_station_id", &models.GasBasic{}), optionalRelation("delivery_basic_identity", "delivery_point_id", &models.DeliveryBasic{})} + list, create, _, update := platform.ResourceHandlers(&models.EcOrder{}, []string{"order_no", "total_amount"}, []string{"total_amount"}, orderRelations...) registerWritableResource(group, "/ec/ec_order", list, create, platform.GetEcOrder, update, &models.EcOrder{}) - registerRestrictedWritableResource(group, "/ec/ec_review", &models.EcReview{}, []string{"ec_order_id", "ec_product_id", "user_account_id", "score", "content"}) + registerRestrictedWritableResource(group, "/ec/ec_order_item", &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) + registerRestrictedWritableResource(group, "/ec/ec_review", &models.EcReview{}, []string{"score", "content"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{}), requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{}), requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) } func registerStaffRoute(group *gin.RouterGroup) { @@ -107,7 +111,15 @@ func registerWritableResource(group *gin.RouterGroup, path string, list, create, resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, model) }) } -func registerRestrictedWritableResource(group *gin.RouterGroup, path string, model any, fields []string) { - list, create, get, update := platform.ResourceHandlers(model, fields, fields) +func registerRestrictedWritableResource(group *gin.RouterGroup, path string, model any, fields []string, relations ...platform.ResourceRelation) { + list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...) registerWritableResource(group, path, list, create, get, update, model) } + +func requiredRelation(input, column string, model any) platform.ResourceRelation { + return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true} +} + +func optionalRelation(input, column string, model any) platform.ResourceRelation { + return platform.ResourceRelation{Input: input, Column: column, Model: model} +} diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index abd1dc9..134a5ff 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -71,7 +71,7 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te for _, resource := range []string{ "/device/dev_smart_cylinder_valve", "/device/dev_device_binding", "/safety/saf_rule", "/safety/saf_event", "/safety/saf_inspection", - "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_review", + "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_order_item", "/ec/ec_review", "/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_track_point", } { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost)