From 8c4e92f6a997bde71e8d8846f222c0eb4f9c7aed Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 02:47:39 +0800 Subject: [PATCH 01/17] feat(platform): add device safety commerce APIs --- .../api/internal/logic/platform/resource.go | 22 +-- .../internal/logic/platform/resource_test.go | 75 +++++++- .../logic/platform/task4_resources.go | 175 ++++++++++++++++++ backend/api/internal/routers/platform.go | 39 ++++ backend/api/internal/routers/platform_test.go | 39 ++++ 5 files changed, 338 insertions(+), 12 deletions(-) create mode 100644 backend/api/internal/logic/platform/task4_resources.go diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index ebe1489..fca350f 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -80,16 +80,16 @@ func ExpectedResources() []ResourceContract { {Domain: "user", Name: "user_service_relation", Mode: Writable, PageKind: "list"}, {Domain: "device", Name: "dev_smart_cylinder_valve", Mode: Writable, PageKind: "list"}, {Domain: "device", Name: "dev_device_binding", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_rule", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_event", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_category", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product_image", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_cart", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_order", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_review", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_rule", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_event", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_category", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_product", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, + {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_review", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_track_point", Mode: Writable, PageKind: "list"}, @@ -113,6 +113,6 @@ func ExpectedResources() []ResourceContract { {Domain: "audit", Name: "aud_operation_log", Mode: ReadOnly, PageKind: "list"}, {Domain: "audit", Name: "aud_export_log", Mode: ReadOnly, PageKind: "list"}, {Domain: "audit", Name: "aud_approval", Mode: ReadOnly, PageKind: "list"}, - {Domain: "device", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, + {Domain: "safety", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, } } diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index e2850df..0457c85 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -7,9 +7,12 @@ import ( "net/http" "net/http/httptest" "regexp" + "strings" "testing" + "time" "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/types" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" @@ -21,7 +24,7 @@ import ( func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") - assertContract(t, ExpectedResources(), "device", "saf_event", Writable, "list") + assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") } @@ -159,6 +162,76 @@ func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t * } } +func TestGetEcOrderReturnsOrderItems(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). + WithArgs("order-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}). + AddRow(uint64(1), "order-a", nil, nil, "enabled", 1, "O-1", uint64(2), uint64(3), uint64(4), int64(500))) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order_item" WHERE ec_order_id = $1 ORDER BY id asc`)). + WithArgs(uint64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}). + AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500))) + + ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil) + GetEcOrder(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) { + t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + +func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track" WHERE identity = $1 ORDER BY "delivery_track"."id" LIMIT $2`)). + WithArgs("track-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_task_id", "started_at", "completed_at"}). + AddRow(uint64(7), "track-a", nil, nil, "enabled", 1, uint64(8), now, nil)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE delivery_track_id = $1 ORDER BY occurred_at asc`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}). + AddRow(uint64(9), "point-a", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456")) + + ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil) + GetDeliveryTrack(ctx) + + assertResponseCode(t, recorder, 0) + if strings.Contains(recorder.Body.String(), "120.123") || strings.Contains(recorder.Body.String(), "30.456") { + t.Fatalf("unauthorized response exposed precise coordinates: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + +func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event" WHERE identity = $1 ORDER BY "saf_event"."id" LIMIT $2`)). + WithArgs("event-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}). + AddRow(uint64(3), "event-a", now, now, "open", 1, "E-1", 2, "alarm", "valve-a", nil)) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE "saf_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). + WithArgs("disposed", sqlmock.AnyArg(), "event-a"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "saf_event_disposal" ("identity","created_at","updated_at","status","version","saf_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "event-a", "close", "resolved", "operator-a"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectCommit() + + ctx, recorder := updateContext(http.MethodPost, "/safety/saf_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + DisposeSafetyEvent(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"operator_identity":"operator-a"`) { + t.Fatalf("disposal omitted its operator: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go new file mode 100644 index 0000000..ab0f7cc --- /dev/null +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -0,0 +1,175 @@ +package platform + +import ( + "encoding/json" + "errors" + "reflect" + + "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" +) + +// 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) { + return func(ctx *gin.Context) { listResource(ctx, model) }, + func(ctx *gin.Context) { createResource(ctx, model, createFields) }, + func(ctx *gin.Context) { getResource(ctx, model) }, + func(ctx *gin.Context) { updateResource(ctx, model, updateFields) } +} + +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 + } + infra.Response.Success(ctx, gin.H{"total": total, "list": list.Elem().Interface()}) +} + +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 + } + infra.Response.Success(ctx, data.Interface()) +} + +func createResource(ctx *gin.Context, model any, allowedFields []string) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); 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) + 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, data.Interface()) +} + +func updateResource(ctx *gin.Context, model any, allowedFields []string) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, model, input, allowedFields) +} + +// 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, 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 + } + infra.Response.Success(ctx, gin.H{"order": order, "items": items}) +} + +// 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 = "" + } + } + infra.Response.Success(ctx, gin.H{"track": track, "points": points}) +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 18f9a54..76babf9 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -26,6 +26,9 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeliveryRoute(protected) registerStaffRoute(protected) registerUserRoute(protected) + registerDeviceRoute(protected) + registerSafetyRoute(protected) + registerCommerceRoute(protected) registerPlatformRoute(protected) } @@ -37,6 +40,37 @@ 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"}) + 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"}) +} + +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"}) + list, _, get, _ := platform.ResourceHandlers(&models.DevTelemetry{}, nil, nil) + telemetry := group.Group("/device/dev_telemetry") + telemetry.GET("", list) + telemetry.GET("/:identity", get) +} + +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"}) + 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"}) + 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"}) } func registerStaffRoute(group *gin.RouterGroup) { @@ -72,3 +106,8 @@ func registerWritableResource(group *gin.RouterGroup, path string, list, create, resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) }) 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) + registerWritableResource(group, path, list, create, get, update, model) +} diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 331f70c..abd1dc9 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -56,6 +56,45 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) } +func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + + 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", + "/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_track_point", + } { + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) + } + + telemetry := "/heqi/platform/v1/device/dev_telemetry" + assertRouteMethods(t, routes, telemetry, http.MethodGet) + assertRouteMethods(t, routes, telemetry+"/:identity", http.MethodGet) + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + if routes[telemetry][method] || routes[telemetry+"/:identity"][method] { + t.Errorf("telemetry unexpectedly permits %s", method) + } + } + + disposal := "/heqi/platform/v1/safety/saf_event/:identity/disposals" + assertRouteMethods(t, routes, disposal, http.MethodPost) + if routes[disposal][http.MethodDelete] { + t.Fatal("safety event disposals must be append-only") + } +} + func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) { t.Helper() for _, method := range methods { From ace24b574cd29343d0843d66c4b9d8087d90603c Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 02:59:28 +0800 Subject: [PATCH 02/17] fix(platform): enforce identity relations --- .../api/internal/logic/platform/resource.go | 1 + .../internal/logic/platform/resource_test.go | 44 +++++++ .../logic/platform/task4_resources.go | 123 +++++++++++++++--- backend/api/internal/routers/platform.go | 40 ++++-- backend/api/internal/routers/platform_test.go | 2 +- 5 files changed, 180 insertions(+), 30 deletions(-) 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) From f4dbf787c2d534a4dee17d80c056c352c65d28f6 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 03:18:41 +0800 Subject: [PATCH 03/17] feat(platform): complete finance content audit APIs --- .../task-5-report.md | 26 ++++++ backend/api/internal/logic/platform/audit.go | 90 +++++++++++++++++++ .../api/internal/logic/platform/audit_test.go | 61 +++++++++++++ .../internal/logic/platform/health_test.go | 31 +++++++ backend/api/internal/models/aud_approval.go | 16 ++-- backend/api/internal/routers/platform.go | 37 ++++++++ backend/api/internal/routers/platform_test.go | 39 ++++++++ 7 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 .superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md create mode 100644 backend/api/internal/logic/platform/audit.go create mode 100644 backend/api/internal/logic/platform/audit_test.go create mode 100644 backend/api/internal/logic/platform/health_test.go diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md new file mode 100644 index 0000000..1d23035 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md @@ -0,0 +1,26 @@ +# Task 5 implementation report + +## RED / GREEN + +- RED: `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues)' -v` failed before implementation because Task 5 routes were absent and the approval update whitelist did not exist. +- GREEN: the same route and approval-contract tests pass. A transactional approval test confirms the approval update only persists status, opinion, handler identity, and handling time, then inserts an `aud_operation_log` in the same transaction. + +## Changes + +- Registered writable, restricted-field finance APIs (`fin_payment`, `fin_settlement`, `fin_reconciliation`) and content/customer-service APIs (`cnt_content`, `ntf_template`, `cs_ticket`). They retain the standard status patch and logical archive behavior. +- Registered wallet, report, and audit record resources as GET-only list/detail APIs. No generic write route is registered for those resources. +- Added `POST /audit/aud_approval/:identity/approve`. It derives the handler identity from the authenticated JWT, records the handling timestamp, limits the update to the four approval fields, and appends before/after audit data atomically. +- Extended `AudApproval` with persisted `handler_identity` and `handled_at` fields. +- Added route, approval/audit-transaction, and empty-dashboard-zero-value coverage. + +## Verification + +- `gofmt -w internal/models/aud_approval.go internal/routers/platform.go internal/routers/platform_test.go internal/logic/platform/audit.go internal/logic/platform/audit_test.go internal/logic/platform/health_test.go` — PASS +- `go test ./internal/logic/platform ./internal/routers -run 'Test(PlatformFinanceContentAndAuditRoutes|ApprovalValues|ApproveAuditOnlyUpdates|DashboardOverviewReturnsZero)' -v` — PASS +- `go test ./...` — PASS +- `go build ./cmd/main` — PASS +- `git diff --check` — PASS + +## Concerns + +- Approval status values are accepted as non-empty strings to preserve the existing status model; a future workflow may want an explicit state-transition policy (for example, only `pending -> approved|rejected`). diff --git a/backend/api/internal/logic/platform/audit.go b/backend/api/internal/logic/platform/audit.go new file mode 100644 index 0000000..e2f4398 --- /dev/null +++ b/backend/api/internal/logic/platform/audit.go @@ -0,0 +1,90 @@ +package platform + +import ( + "encoding/json" + "errors" + "time" + + "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" +) + +func approvalValues(status, opinion, operatorIdentity string) map[string]any { + return map[string]any{ + "status": status, + "opinion": opinion, + "handler_identity": operatorIdentity, + "handled_at": time.Now().UTC(), + } +} + +// ApproveAudit records the reviewer and decision without allowing an approval +// to mutate any business fields. The operation audit is created atomically with +// the approval update. +func ApproveAudit(ctx *gin.Context) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var request struct { + Status string `json:"status" binding:"required,max=32"` + Opinion string `json:"opinion" binding:"max=2000"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + + values := approvalValues(request.Status, request.Opinion, claims.Identity) + var approval models.AudApproval + err = impl.DBService.Transaction(func(transaction *gorm.DB) error { + if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&approval).Error; err != nil { + return err + } + before, err := json.Marshal(gin.H{ + "status": approval.Status, "opinion": approval.Opinion, + "handler_identity": approval.HandlerIdentity, "handled_at": approval.HandledAt, + }) + if err != nil { + return err + } + if result := transaction.Model(&models.AudApproval{}).Where("identity = ?", approval.Identity).Updates(values); result.Error != nil { + return result.Error + } else if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + after, err := json.Marshal(values) + if err != nil { + return err + } + return transaction.Create(&models.AudOperationLog{ + Entity: newEntity("enabled"), + OperatorIdentity: claims.Identity, + Action: "approve", + ObjectType: "aud_approval", + ObjectIdentity: approval.Identity, + BeforeData: string(before), + AfterData: string(after), + }).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Error(ctx, err) + return + } + approval.Status = request.Status + approval.Opinion = request.Opinion + approval.HandlerIdentity = claims.Identity + handledAt := values["handled_at"].(time.Time) + approval.HandledAt = &handledAt + infra.Response.Success(ctx, resourceResponse(approval)) +} diff --git a/backend/api/internal/logic/platform/audit_test.go b/backend/api/internal/logic/platform/audit_test.go new file mode 100644 index 0000000..e18513c --- /dev/null +++ b/backend/api/internal/logic/platform/audit_test.go @@ -0,0 +1,61 @@ +package platform + +import ( + "database/sql/driver" + "net/http" + "regexp" + "strings" + "testing" + "time" + + "git.apinb.com/bsm-sdk/core/types" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestApprovalValuesOnlyChangesApprovalFieldsAndRecordsOperator(t *testing.T) { + values := approvalValues("approved", "accepted", "operator-a") + + if values["status"] != "approved" || values["opinion"] != "accepted" || values["handler_identity"] != "operator-a" { + t.Fatalf("approval values do not retain the approved state, opinion, and operator: %#v", values) + } + if _, ok := values["handled_at"]; !ok { + t.Fatalf("approval values do not record handling time: %#v", values) + } + if len(values) != 4 { + t.Fatalf("approval update includes fields outside its whitelist: %#v", values) + } +} + +func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)). + WithArgs("approval-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}). + AddRow(uint64(1), "approval-a", now, now, "pending", 1, "refund", "payment-a", "applicant-a", "", "", nil)) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6`)). + WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "aud_operation_log" ("identity","created_at","updated_at","status","version","operator_identity","action","object_type","object_identity","before_data","after_data") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING "id"`)). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "operator-a", "approve", "aud_approval", "approval-a", jsonContaining(`"status":"pending"`), jsonContaining(`"handler_identity":"operator-a"`)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(2))) + mock.ExpectCommit() + + ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved","opinion":"accepted","business_identity":"payment-b"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + ApproveAudit(ctx) + + assertMockExpectations(t, mock) + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"handler_identity":"operator-a"`) { + t.Fatalf("approval response omitted its handler: %s", recorder.Body.String()) + } +} + +type jsonContaining string + +func (expected jsonContaining) Match(value driver.Value) bool { + actual, ok := value.(string) + return ok && strings.Contains(actual, string(expected)) +} diff --git a/backend/api/internal/logic/platform/health_test.go b/backend/api/internal/logic/platform/health_test.go new file mode 100644 index 0000000..921198d --- /dev/null +++ b/backend/api/internal/logic/platform/health_test.go @@ -0,0 +1,31 @@ +package platform + +import ( + "regexp" + "testing" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" +) + +func TestDashboardOverviewReturnsZeroValuesForAnEmptyDatabase(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + for _, query := range []string{ + `SELECT count\(\*\) FROM "gas_basic" WHERE status = \$1`, + `SELECT count\(\*\) FROM "delivery_basic" WHERE status = \$1`, + `SELECT count\(\*\) FROM "staff_account" WHERE work_status = \$1`, + `SELECT count\(\*\) FROM "user_account" WHERE status = \$1`, + `SELECT count\(\*\) FROM "saf_event" WHERE status = \$1`, + } { + mock.ExpectQuery(regexp.MustCompile(query).String()).WithArgs(sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + } + + overview, err := models.GetDashboardOverview() + if err != nil { + t.Fatal(err) + } + if overview != (models.DashboardOverview{}) { + t.Fatalf("empty dashboard = %#v, want zero values", overview) + } + assertMockExpectations(t, mock) +} diff --git a/backend/api/internal/models/aud_approval.go b/backend/api/internal/models/aud_approval.go index a028fc3..3129044 100644 --- a/backend/api/internal/models/aud_approval.go +++ b/backend/api/internal/models/aud_approval.go @@ -1,14 +1,20 @@ package models -import "git.apinb.com/bsm-sdk/core/database" +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) // AudApproval 对应 aud_approval,保存审批流与复核意见。 type AudApproval struct { Entity - BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` - BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` - ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` - Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` + BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` + BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` + ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` + Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` + HandlerIdentity string `gorm:"column:handler_identity;type:varchar(36);not null;default:'';index" json:"handler_identity"` + HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"` } func init() { database.AppendMigrate(&AudApproval{}) } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 89a5baf..f606e75 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -29,6 +29,9 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeviceRoute(protected) registerSafetyRoute(protected) registerCommerceRoute(protected) + registerFinanceRoute(protected) + registerContentRoute(protected) + registerAuditRoute(protected) registerPlatformRoute(protected) } @@ -101,6 +104,33 @@ func registerPlatformRoute(group *gin.RouterGroup) { registerWritableResource(group, "/platform/platform_menu", platform.ListPlatformMenu, platform.CreatePlatformMenu, platform.GetPlatformMenu, platform.UpdatePlatformMenu, &models.PlatformMenu{}) } +func registerFinanceRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/finance/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) + registerRestrictedWritableResource(group, "/finance/fin_settlement", &models.FinSettlement{}, []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}) + registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) + + registerReadOnlyResource(group, "/wallet/wallet", &models.Wallet{}) + registerReadOnlyResource(group, "/wallet/wallet_ledger", &models.WalletLedger{}) + registerReadOnlyResource(group, "/wallet/wallet_recharge", &models.WalletRecharge{}) + registerReadOnlyResource(group, "/wallet/wallet_withdrawal", &models.WalletWithdrawal{}) + registerReadOnlyResource(group, "/report/report", &models.Report{}) + registerReadOnlyResource(group, "/report/report_item", &models.ReportItem{}) + registerReadOnlyResource(group, "/report/report_metric_snapshot", &models.ReportMetricSnapshot{}) +} + +func registerContentRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/content/cnt_content", &models.CntContent{}, []string{"content_type", "title", "body", "version_no", "publish_status"}) + registerRestrictedWritableResource(group, "/notification/ntf_template", &models.NtfTemplate{}, []string{"template_code", "channel", "content"}) + registerRestrictedWritableResource(group, "/customer_service/cs_ticket", &models.CsTicket{}, []string{"ticket_no", "category", "priority"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{})) +} + +func registerAuditRoute(group *gin.RouterGroup) { + registerReadOnlyResource(group, "/audit/aud_operation_log", &models.AudOperationLog{}) + registerReadOnlyResource(group, "/audit/aud_export_log", &models.AudExportLog{}) + registerReadOnlyResource(group, "/audit/aud_approval", &models.AudApproval{}) + group.POST("/audit/aud_approval/:identity/approve", platform.ApproveAudit) +} + func registerWritableResource(group *gin.RouterGroup, path string, list, create, get, update gin.HandlerFunc, model any) { resource := group.Group(path) resource.GET("", list) @@ -116,6 +146,13 @@ func registerRestrictedWritableResource(group *gin.RouterGroup, path string, mod registerWritableResource(group, path, list, create, get, update, model) } +func registerReadOnlyResource(group *gin.RouterGroup, path string, model any) { + list, _, get, _ := platform.ResourceHandlers(model, nil, nil) + resource := group.Group(path) + resource.GET("", list) + resource.GET("/:identity", get) +} + func requiredRelation(input, column string, model any) platform.ResourceRelation { return platform.ResourceRelation{Input: input, Column: column, Model: model, Required: true} } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 134a5ff..22d096e 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -95,6 +95,45 @@ func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *te } } +func TestPlatformFinanceContentAndAuditRoutesFollowTheirContracts(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + + for _, resource := range []string{ + "/finance/fin_payment", "/finance/fin_settlement", "/finance/fin_reconciliation", + "/content/cnt_content", "/notification/ntf_template", "/customer_service/cs_ticket", + } { + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) + } + + for _, resource := range []string{ + "/wallet/wallet", "/wallet/wallet_ledger", "/wallet/wallet_recharge", "/wallet/wallet_withdrawal", + "/report/report", "/report/report_item", "/report/report_metric_snapshot", + "/audit/aud_operation_log", "/audit/aud_export_log", "/audit/aud_approval", + } { + path := "/heqi/platform/v1" + resource + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + if routes[path][method] || routes[path+"/:identity"][method] { + t.Errorf("read-only resource %s unexpectedly permits %s", resource, method) + } + } + } + + assertRouteMethods(t, routes, "/heqi/platform/v1/audit/aud_approval/:identity/approve", http.MethodPost) +} + func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) { t.Helper() for _, method := range methods { From 4068b9dc607f8d0007f1a46f7118cc6bd46bf6e7 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 09:14:36 +0800 Subject: [PATCH 04/17] fix(platform): guard approval transitions --- .../task-5-report.md | 11 +++ backend/api/internal/logic/platform/audit.go | 17 +++- .../api/internal/logic/platform/audit_test.go | 78 ++++++++++++++++++- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md index 1d23035..f240018 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md @@ -24,3 +24,14 @@ ## Concerns - Approval status values are accepted as non-empty strings to preserve the existing status model; a future workflow may want an explicit state-transition policy (for example, only `pending -> approved|rejected`). + +## Fix round 1: P1 approval transition guard + +### RED / GREEN + +- RED: approval accepted arbitrary non-empty states, updated every matching identity regardless of its current status, and did not compare the JWT operator with the applicant. The new tests reproduced invalid state acceptance, repeat processing, self-approval, and a concurrent second decision after a pending read. +- GREEN: only `approved` and `rejected` requests proceed. The approval must still be `pending`, the applicant cannot decide it, and the update predicate is `identity AND status = pending`. A zero-row conditional update is rejected and does not append an operation audit. + +### Verification + +- `go test ./internal/logic/platform -run 'TestApproveAudit' -count=1 -v` — PASS diff --git a/backend/api/internal/logic/platform/audit.go b/backend/api/internal/logic/platform/audit.go index e2f4398..1918ab8 100644 --- a/backend/api/internal/logic/platform/audit.go +++ b/backend/api/internal/logic/platform/audit.go @@ -14,6 +14,8 @@ import ( "gorm.io/gorm" ) +var errApprovalNotProcessable = errors.New("approval is not processable") + func approvalValues(status, opinion, operatorIdentity string) map[string]any { return map[string]any{ "status": status, @@ -40,6 +42,10 @@ func ApproveAudit(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if request.Status != "approved" && request.Status != "rejected" { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } values := approvalValues(request.Status, request.Opinion, claims.Identity) var approval models.AudApproval @@ -47,6 +53,9 @@ func ApproveAudit(ctx *gin.Context) { if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&approval).Error; err != nil { return err } + if approval.Status != "pending" || approval.ApplicantIdentity == claims.Identity { + return errApprovalNotProcessable + } before, err := json.Marshal(gin.H{ "status": approval.Status, "opinion": approval.Opinion, "handler_identity": approval.HandlerIdentity, "handled_at": approval.HandledAt, @@ -54,10 +63,10 @@ func ApproveAudit(ctx *gin.Context) { if err != nil { return err } - if result := transaction.Model(&models.AudApproval{}).Where("identity = ?", approval.Identity).Updates(values); result.Error != nil { + if result := transaction.Model(&models.AudApproval{}).Where("identity = ? AND status = ?", approval.Identity, "pending").Updates(values); result.Error != nil { return result.Error } else if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + return errApprovalNotProcessable } after, err := json.Marshal(values) if err != nil { @@ -74,6 +83,10 @@ func ApproveAudit(ctx *gin.Context) { }).Error }) if err != nil { + if errors.Is(err, errApprovalNotProcessable) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } if errors.Is(err, gorm.ErrRecordNotFound) { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return diff --git a/backend/api/internal/logic/platform/audit_test.go b/backend/api/internal/logic/platform/audit_test.go index e18513c..024d5cd 100644 --- a/backend/api/internal/logic/platform/audit_test.go +++ b/backend/api/internal/logic/platform/audit_test.go @@ -8,8 +8,10 @@ import ( "testing" "time" + "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/types" "github.com/DATA-DOG/go-sqlmock" + "google.golang.org/grpc/status" ) func TestApprovalValuesOnlyChangesApprovalFieldsAndRecordsOperator(t *testing.T) { @@ -34,8 +36,8 @@ func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testin WithArgs("approval-a", 1). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}). AddRow(uint64(1), "approval-a", now, now, "pending", 1, "refund", "payment-a", "applicant-a", "", "", nil)) - mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6`)). - WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a"). + mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)). + WithArgs(sqlmock.AnyArg(), "operator-a", "accepted", "approved", sqlmock.AnyArg(), "approval-a", "pending"). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "aud_operation_log" ("identity","created_at","updated_at","status","version","operator_identity","action","object_type","object_identity","before_data","after_data") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING "id"`)). WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "operator-a", "approve", "aud_approval", "approval-a", jsonContaining(`"status":"pending"`), jsonContaining(`"handler_identity":"operator-a"`)). @@ -53,6 +55,78 @@ func TestApproveAuditOnlyUpdatesApprovalFieldsAndAppendsOperationAudit(t *testin } } +func TestApproveAuditRejectsStatusesOutsideApprovedAndRejected(t *testing.T) { + for _, decision := range []string{"pending", "archived"} { + t.Run(decision, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"`+decision+`"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + + ApproveAudit(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) + }) + } +} + +func TestApproveAuditRejectsAlreadyHandledApproval(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)). + WithArgs("approval-a", 1). + WillReturnRows(approvalRows("approval-a", "approved", "applicant-a")) + mock.ExpectRollback() + + ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + ApproveAudit(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) +} + +func TestApproveAuditRejectsTheApplicant(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)). + WithArgs("approval-a", 1). + WillReturnRows(approvalRows("approval-a", "pending", "operator-a")) + mock.ExpectRollback() + + ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + ApproveAudit(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) +} + +func TestApproveAuditRejectsAConcurrentSecondDecision(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "aud_approval" WHERE identity = $1 ORDER BY "aud_approval"."id" LIMIT $2`)). + WithArgs("approval-a", 1). + WillReturnRows(approvalRows("approval-a", "pending", "applicant-a")) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE "aud_approval" SET "handled_at"=$1,"handler_identity"=$2,"opinion"=$3,"status"=$4,"updated_at"=$5 WHERE identity = $6 AND status = $7`)). + WithArgs(sqlmock.AnyArg(), "operator-a", "", "approved", sqlmock.AnyArg(), "approval-a", "pending"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectRollback() + + ctx, recorder := updateContext(http.MethodPost, "/audit/aud_approval/approval-a/approve", "approval-a", []byte(`{"status":"approved"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + ApproveAudit(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) +} + +func approvalRows(identity, approvalStatus, applicant string) *sqlmock.Rows { + now := time.Now().UTC() + return sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "business_type", "business_identity", "applicant_identity", "opinion", "handler_identity", "handled_at"}). + AddRow(uint64(1), identity, now, now, approvalStatus, 1, "refund", "payment-a", applicant, "", "", nil) +} + type jsonContaining string func (expected jsonContaining) Match(value driver.Value) bool { From b0a44221b52a3ed5e72c975598e638b56fa2790e Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 09:26:52 +0800 Subject: [PATCH 05/17] feat: complete platform administration pages --- .../task-6-report.md | 23 +++++++++ frontend/platform_admin/src/api/platform.ts | 2 +- frontend/platform_admin/src/api/resource.ts | 5 +- frontend/platform_admin/src/api/resources.ts | 39 ++++++++++++++ frontend/platform_admin/src/api/staff.ts | 2 +- frontend/platform_admin/src/api/user.ts | 2 +- .../src/router/routes/modules/audit.ts | 12 +++++ .../src/router/routes/modules/commerce.ts | 16 ++++++ .../src/router/routes/modules/content.ts | 10 ++++ .../router/routes/modules/customer_service.ts | 10 ++++ .../src/router/routes/modules/delivery.ts | 14 +++++ .../src/router/routes/modules/device.ts | 16 ++++++ .../src/router/routes/modules/finance.ts | 12 +++++ .../src/router/routes/modules/gas.ts | 11 ++++ .../src/router/routes/modules/notification.ts | 10 ++++ .../src/router/routes/modules/platform.ts | 29 +++++++---- .../src/router/routes/modules/report.ts | 12 +++++ .../src/router/routes/modules/staff.ts | 11 ++++ .../src/router/routes/modules/user.ts | 12 +++++ .../src/router/routes/modules/wallet.ts | 13 +++++ .../src/views/audit/aud_approval/ListPage.vue | 6 +++ .../views/audit/aud_export_log/ListPage.vue | 6 +++ .../audit/aud_operation_log/ListPage.vue | 6 +++ .../src/views/commerce/ec_cart/ListPage.vue | 6 +++ .../views/commerce/ec_category/ListPage.vue | 6 +++ .../src/views/commerce/ec_order/ListPage.vue | 6 +++ .../views/commerce/ec_product/ListPage.vue | 6 +++ .../ec_product_attribute/ListPage.vue | 6 +++ .../commerce/ec_product_image/ListPage.vue | 6 +++ .../src/views/commerce/ec_review/ListPage.vue | 6 +++ .../views/content/cnt_content/ListPage.vue | 6 +++ .../customer_service/cs_ticket/ListPage.vue | 6 +++ .../delivery/delivery_account/ListPage.vue | 6 +++ .../delivery/delivery_basic/ListPage.vue | 6 +++ .../views/delivery/delivery_task/ListPage.vue | 6 +++ .../delivery/delivery_track/ListPage.vue | 6 +++ .../delivery_track_point/ListPage.vue | 6 +++ .../device/dev_device_binding/ListPage.vue | 6 +++ .../dev_smart_cylinder_valve/ListPage.vue | 6 +++ .../views/device/dev_telemetry/ListPage.vue | 6 +++ .../src/views/device/saf_event/ListPage.vue | 6 +++ .../device/saf_event_disposal/ListPage.vue | 6 +++ .../views/device/saf_inspection/ListPage.vue | 6 +++ .../src/views/device/saf_rule/ListPage.vue | 6 +++ .../views/finance/fin_payment/ListPage.vue | 6 +++ .../finance/fin_reconciliation/ListPage.vue | 6 +++ .../views/finance/fin_settlement/ListPage.vue | 6 +++ .../src/views/gas/gas_account/ListPage.vue | 6 +++ .../src/views/gas/gas_basic/ListPage.vue | 6 +++ .../notification/ntf_template/ListPage.vue | 6 +++ .../src/views/platform/menu/TreePage.vue | 7 ++- .../views/platform/platform_menu/TreePage.vue | 6 +++ .../views/platform/platform_role/ListPage.vue | 6 +++ .../platform/platfrom_account/ListPage.vue | 6 +++ .../src/views/report/report/ListPage.vue | 6 +++ .../src/views/report/report_item/ListPage.vue | 6 +++ .../report_metric_snapshot/ListPage.vue | 6 +++ .../src/views/shared/CrudListPage.vue | 51 +++++++++++-------- .../src/views/shared/ReadOnlyListPage.vue | 6 +++ .../src/views/shared/TreePage.vue | 24 +++++++++ .../views/staff/staff_account/ListPage.vue | 6 +++ .../views/staff/staff_credential/ListPage.vue | 6 +++ .../src/views/user/user_account/ListPage.vue | 6 +++ .../src/views/user/user_address/ListPage.vue | 6 +++ .../user/user_service_relation/ListPage.vue | 6 +++ .../src/views/wallet/wallet/ListPage.vue | 6 +++ .../views/wallet/wallet_ledger/ListPage.vue | 6 +++ .../views/wallet/wallet_recharge/ListPage.vue | 6 +++ .../wallet/wallet_withdrawal/ListPage.vue | 6 +++ 69 files changed, 584 insertions(+), 35 deletions(-) create mode 100644 .superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md create mode 100644 frontend/platform_admin/src/api/resources.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/audit.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/commerce.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/content.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/customer_service.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/delivery.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/device.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/finance.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/gas.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/notification.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/report.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/staff.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/user.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/wallet.ts create mode 100644 frontend/platform_admin/src/views/audit/aud_approval/ListPage.vue create mode 100644 frontend/platform_admin/src/views/audit/aud_export_log/ListPage.vue create mode 100644 frontend/platform_admin/src/views/audit/aud_operation_log/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue create mode 100644 frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue create mode 100644 frontend/platform_admin/src/views/content/cnt_content/ListPage.vue create mode 100644 frontend/platform_admin/src/views/customer_service/cs_ticket/ListPage.vue create mode 100644 frontend/platform_admin/src/views/delivery/delivery_account/ListPage.vue create mode 100644 frontend/platform_admin/src/views/delivery/delivery_basic/ListPage.vue create mode 100644 frontend/platform_admin/src/views/delivery/delivery_task/ListPage.vue create mode 100644 frontend/platform_admin/src/views/delivery/delivery_track/ListPage.vue create mode 100644 frontend/platform_admin/src/views/delivery/delivery_track_point/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/dev_device_binding/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/dev_smart_cylinder_valve/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/dev_telemetry/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/saf_event/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue create mode 100644 frontend/platform_admin/src/views/device/saf_rule/ListPage.vue create mode 100644 frontend/platform_admin/src/views/finance/fin_payment/ListPage.vue create mode 100644 frontend/platform_admin/src/views/finance/fin_reconciliation/ListPage.vue create mode 100644 frontend/platform_admin/src/views/finance/fin_settlement/ListPage.vue create mode 100644 frontend/platform_admin/src/views/gas/gas_account/ListPage.vue create mode 100644 frontend/platform_admin/src/views/gas/gas_basic/ListPage.vue create mode 100644 frontend/platform_admin/src/views/notification/ntf_template/ListPage.vue create mode 100644 frontend/platform_admin/src/views/platform/platform_menu/TreePage.vue create mode 100644 frontend/platform_admin/src/views/platform/platform_role/ListPage.vue create mode 100644 frontend/platform_admin/src/views/platform/platfrom_account/ListPage.vue create mode 100644 frontend/platform_admin/src/views/report/report/ListPage.vue create mode 100644 frontend/platform_admin/src/views/report/report_item/ListPage.vue create mode 100644 frontend/platform_admin/src/views/report/report_metric_snapshot/ListPage.vue create mode 100644 frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue create mode 100644 frontend/platform_admin/src/views/shared/TreePage.vue create mode 100644 frontend/platform_admin/src/views/staff/staff_account/ListPage.vue create mode 100644 frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue create mode 100644 frontend/platform_admin/src/views/user/user_account/ListPage.vue create mode 100644 frontend/platform_admin/src/views/user/user_address/ListPage.vue create mode 100644 frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue create mode 100644 frontend/platform_admin/src/views/wallet/wallet/ListPage.vue create mode 100644 frontend/platform_admin/src/views/wallet/wallet_ledger/ListPage.vue create mode 100644 frontend/platform_admin/src/views/wallet/wallet_recharge/ListPage.vue create mode 100644 frontend/platform_admin/src/views/wallet/wallet_withdrawal/ListPage.vue diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md new file mode 100644 index 0000000..aba0882 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -0,0 +1,23 @@ +# Task 6 report + +## RED baseline + +`pnpm audit:platform` initially exited with code 1. The audit reported missing API declarations, route entries, and view files for the resource contract catalogue. + +## Implementation + +- Added `src/api/resources.ts` as the single front-end catalogue for all 46 backend resource contracts, including path, title, mode, page kind, and fields. +- Added route modules and declarative list/tree pages for each resource. +- Added reusable read-only and tree pages; read-only definitions do not render create, edit, status, or archive controls. +- Updated the shared CRUD page with server-side keyword filters, identity-based details, typed form data, archive confirmation, and API error messages. +- Removed platform UI use of internal numeric identifiers in favour of `identity` and `parent_identity`. + +## Verification + +- `pnpm type:check` — pass +- `pnpm audit:platform` — pass +- `pnpm build` — pass + +## Notes + +The tree page consumes `parent_identity`; the corresponding menu API must expose that identity relation rather than an internal numeric parent key. diff --git a/frontend/platform_admin/src/api/platform.ts b/frontend/platform_admin/src/api/platform.ts index 6a0d29b..c156d50 100644 --- a/frontend/platform_admin/src/api/platform.ts +++ b/frontend/platform_admin/src/api/platform.ts @@ -3,7 +3,7 @@ import { resourceApi } from './resource'; /** 平台角色、菜单、账号和工作台接口。 */ export type PlatformRole = { identity: string; role_code: string; name: string; data_scope: string; is_system: boolean; status: string }; -export type PlatformMenu = { id: number; identity: string; parent_id: number; menu_code: string; name: string; icon: string; path: string; sort_no: number }; +export type PlatformMenu = { identity: string; parent_identity?: string; menu_code: string; name: string; icon: string; path: string; sort_no: number }; export const platformApi = { overview: () => request>('/dashboard/overview'), diff --git a/frontend/platform_admin/src/api/resource.ts b/frontend/platform_admin/src/api/resource.ts index 3f3eaa1..09067ee 100644 --- a/frontend/platform_admin/src/api/resource.ts +++ b/frontend/platform_admin/src/api/resource.ts @@ -2,7 +2,10 @@ import { request, type PageResult } from './http'; /** 所有标准 CRUD 资源共享的调用方法。 */ export const resourceApi = { - list: (resource: string, page = 1, size = 20) => request>(`${resource}?page=${page}&size=${size}`), + list: (resource: string, page = 1, size = 20, filters: Record = {}) => { + const query = new URLSearchParams({ page: String(page), size: String(size), ...filters }); + return request>(`${resource}?${query.toString()}`); + }, detail: (resource: string, identity: string) => request(`${resource}/${identity}`), create: (resource: string, data: Record) => request(resource, { method: 'POST', body: JSON.stringify(data) }), update: (resource: string, identity: string, data: Record) => request(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }), diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts new file mode 100644 index 0000000..c096ad0 --- /dev/null +++ b/frontend/platform_admin/src/api/resources.ts @@ -0,0 +1,39 @@ +export type ResourceMode = 'writable' | 'readonly' | 'append_only'; +export type ResourcePageKind = 'list' | 'tree' | 'dashboard'; +export type ResourceField = { key: string; label: string; required?: boolean }; +export type ResourceUiDefinition = { + key: string; + resource: string; + title: string; + mode: ResourceMode; + pageKind: ResourcePageKind; + fields: ResourceField[]; +}; + +const fields: ResourceField[] = [{ key: 'name', label: '名称' }, { key: 'status', label: '状态' }]; +const resource = (domain: string, name: string, title: string, mode: ResourceMode = 'writable', pageKind: ResourcePageKind = 'list'): ResourceUiDefinition => ({ + key: name.replace(/_/g, '-'), resource: `/${domain}/${name}`, title, mode, pageKind, fields, +}); + +/** The single UI catalogue mirrors the backend ExpectedResources contract. */ +export const resources: ResourceUiDefinition[] = [ + resource('gas', 'gas_basic', '气站管理'), resource('gas', 'gas_account', '气站账户'), + resource('delivery', 'delivery_basic', '配送点管理'), resource('delivery', 'delivery_account', '配送账户'), resource('delivery', 'delivery_task', '配送任务'), resource('delivery', 'delivery_track', '配送轨迹'), resource('delivery', 'delivery_track_point', '轨迹点'), + resource('staff', 'staff_account', '服务人员'), resource('staff', 'staff_credential', '人员资质'), + resource('user', 'user_account', '用户账户'), resource('user', 'user_address', '用户地址'), resource('user', 'user_service_relation', '用户服务关系'), + resource('device', 'dev_smart_cylinder_valve', '智能钢瓶阀'), resource('device', 'dev_device_binding', '设备绑定'), resource('device', 'dev_telemetry', '设备遥测', 'readonly'), resource('device', 'saf_rule', '安全规则'), resource('device', 'saf_event', '安全事件'), resource('device', 'saf_event_disposal', '事件处置记录', 'append_only'), resource('device', 'saf_inspection', '安全检查'), + resource('commerce', 'ec_category', '商品分类'), resource('commerce', 'ec_product', '商品'), resource('commerce', 'ec_product_attribute', '商品属性'), resource('commerce', 'ec_product_image', '商品图片'), resource('commerce', 'ec_cart', '购物车'), resource('commerce', 'ec_order', '订单'), resource('commerce', 'ec_review', '评价'), + resource('finance', 'fin_payment', '支付记录'), resource('finance', 'fin_settlement', '结算记录'), resource('finance', 'fin_reconciliation', '对账记录'), + resource('content', 'cnt_content', '内容管理'), resource('notification', 'ntf_template', '通知模板'), resource('customer_service', 'cs_ticket', '客服工单'), + resource('platform', 'platfrom_account', '平台账户'), resource('platform', 'platform_role', '平台角色'), resource('platform', 'platform_menu', '平台菜单', 'writable', 'tree'), + resource('wallet', 'wallet', '钱包', 'readonly'), resource('wallet', 'wallet_ledger', '钱包流水', 'readonly'), resource('wallet', 'wallet_recharge', '钱包充值', 'readonly'), resource('wallet', 'wallet_withdrawal', '钱包提现', 'readonly'), + resource('report', 'report', '报表', 'readonly'), resource('report', 'report_item', '报表项目', 'readonly'), resource('report', 'report_metric_snapshot', '指标快照', 'readonly'), + resource('audit', 'aud_operation_log', '操作审计', 'readonly'), resource('audit', 'aud_export_log', '导出审计', 'readonly'), resource('audit', 'aud_approval', '审批审计', 'readonly'), +]; + +export const resourceByName = Object.fromEntries(resources.map((definition) => [definition.resource, definition])) as Record; +export function getResource(resourcePath: string): ResourceUiDefinition { + const definition = resourceByName[resourcePath]; + if (!definition) throw new Error(`Unknown resource: ${resourcePath}`); + return definition; +} diff --git a/frontend/platform_admin/src/api/staff.ts b/frontend/platform_admin/src/api/staff.ts index 8331058..d8de0ac 100644 --- a/frontend/platform_admin/src/api/staff.ts +++ b/frontend/platform_admin/src/api/staff.ts @@ -2,4 +2,4 @@ import { resourceApi } from './resource'; /** 服务人员管理接口。 */ export type Staff = { identity: string; name: string; phone: string; role_code: string; work_status: string; status: string }; -export const staffApi = { list: () => resourceApi.list('/staff/staff'), create: (data: Record) => resourceApi.create('/staff/staff', data) }; +export const staffApi = { list: () => resourceApi.list('/staff/staff_account'), create: (data: Record) => resourceApi.create('/staff/staff_account', data) }; diff --git a/frontend/platform_admin/src/api/user.ts b/frontend/platform_admin/src/api/user.ts index b4298e5..9fea3d0 100644 --- a/frontend/platform_admin/src/api/user.ts +++ b/frontend/platform_admin/src/api/user.ts @@ -2,4 +2,4 @@ import { resourceApi } from './resource'; /** 业主客户管理接口。 */ export type User = { identity: string; name: string; phone: string; real_name: string; status: string }; -export const userApi = { list: () => resourceApi.list('/user/user'), create: (data: Record) => resourceApi.create('/user/user', data) }; +export const userApi = { list: () => resourceApi.list('/user/user_account'), create: (data: Record) => resourceApi.create('/user/user_account', data) }; diff --git a/frontend/platform_admin/src/router/routes/modules/audit.ts b/frontend/platform_admin/src/router/routes/modules/audit.ts new file mode 100644 index 0000000..70f6d66 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/audit.ts @@ -0,0 +1,12 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/audit', name: 'audit', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.audit', requiresAuth: true, icon: 'icon-apps', order: 22 }, + children: [ + { path: 'aud-operation-log', name: 'audit-aud-operation-log', component: () => import('@/views/audit/aud_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_operation_log', requiresAuth: true, roles: ['*'] } }, + { path: 'aud-export-log', name: 'audit-aud-export-log', component: () => import('@/views/audit/aud_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_export_log', requiresAuth: true, roles: ['*'] } }, + { path: 'aud-approval', name: 'audit-aud-approval', component: () => import('@/views/audit/aud_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_approval', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/commerce.ts b/frontend/platform_admin/src/router/routes/modules/commerce.ts new file mode 100644 index 0000000..128f09b --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/commerce.ts @@ -0,0 +1,16 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/commerce', name: 'commerce', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.commerce', requiresAuth: true, icon: 'icon-apps', order: 15 }, + children: [ + { path: 'ec-category', name: 'commerce-ec-category', component: () => import('@/views/commerce/ec_category/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_category', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product', name: 'commerce-ec-product', component: () => import('@/views/commerce/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product-attribute', name: 'commerce-ec-product-attribute', component: () => import('@/views/commerce/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product_attribute', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product-image', name: 'commerce-ec-product-image', component: () => import('@/views/commerce/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product_image', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-cart', name: 'commerce-ec-cart', component: () => import('@/views/commerce/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_cart', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-order', name: 'commerce-ec-order', component: () => import('@/views/commerce/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_order', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-review', name: 'commerce-ec-review', component: () => import('@/views/commerce/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_review', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/content.ts b/frontend/platform_admin/src/router/routes/modules/content.ts new file mode 100644 index 0000000..dc97fa6 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/content.ts @@ -0,0 +1,10 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/content', name: 'content', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.content', requiresAuth: true, icon: 'icon-apps', order: 17 }, + children: [ + { path: 'cnt-content', name: 'content-cnt-content', component: () => import('@/views/content/cnt_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cnt_content', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/customer_service.ts b/frontend/platform_admin/src/router/routes/modules/customer_service.ts new file mode 100644 index 0000000..dde2a0d --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/customer_service.ts @@ -0,0 +1,10 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/customer_service', name: 'customer_service', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.customer_service', requiresAuth: true, icon: 'icon-apps', order: 19 }, + children: [ + { path: 'cs-ticket', name: 'customer_service-cs-ticket', component: () => import('@/views/customer_service/cs_ticket/ListPage.vue'), meta: { locale: 'menu.platform.customer_service.cs_ticket', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/delivery.ts b/frontend/platform_admin/src/router/routes/modules/delivery.ts new file mode 100644 index 0000000..5711b73 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/delivery.ts @@ -0,0 +1,14 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-apps', order: 11 }, + children: [ + { path: 'delivery-basic', name: 'delivery-delivery-basic', component: () => import('@/views/delivery/delivery_basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_basic', requiresAuth: true, roles: ['*'] } }, + { path: 'delivery-account', name: 'delivery-delivery-account', component: () => import('@/views/delivery/delivery_account/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_account', requiresAuth: true, roles: ['*'] } }, + { path: 'delivery-task', name: 'delivery-delivery-task', component: () => import('@/views/delivery/delivery_task/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_task', requiresAuth: true, roles: ['*'] } }, + { path: 'delivery-track', name: 'delivery-delivery-track', component: () => import('@/views/delivery/delivery_track/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track', requiresAuth: true, roles: ['*'] } }, + { path: 'delivery-track-point', name: 'delivery-delivery-track-point', component: () => import('@/views/delivery/delivery_track_point/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track_point', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/device.ts b/frontend/platform_admin/src/router/routes/modules/device.ts new file mode 100644 index 0000000..2198db9 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/device.ts @@ -0,0 +1,16 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/device', name: 'device', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.device', requiresAuth: true, icon: 'icon-apps', order: 14 }, + children: [ + { path: 'dev-smart-cylinder-valve', name: 'device-dev-smart-cylinder-valve', component: () => import('@/views/device/dev_smart_cylinder_valve/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_smart_cylinder_valve', requiresAuth: true, roles: ['*'] } }, + { path: 'dev-device-binding', name: 'device-dev-device-binding', component: () => import('@/views/device/dev_device_binding/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_device_binding', requiresAuth: true, roles: ['*'] } }, + { path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-rule', name: 'device-saf-rule', component: () => import('@/views/device/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_rule', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-event', name: 'device-saf-event', component: () => import('@/views/device/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_event', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-event-disposal', name: 'device-saf-event-disposal', component: () => import('@/views/device/saf_event_disposal/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_event_disposal', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-inspection', name: 'device-saf-inspection', component: () => import('@/views/device/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_inspection', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/finance.ts b/frontend/platform_admin/src/router/routes/modules/finance.ts new file mode 100644 index 0000000..18d6db9 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/finance.ts @@ -0,0 +1,12 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/finance', name: 'finance', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.finance', requiresAuth: true, icon: 'icon-apps', order: 16 }, + children: [ + { path: 'fin-payment', name: 'finance-fin-payment', component: () => import('@/views/finance/fin_payment/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_payment', requiresAuth: true, roles: ['*'] } }, + { path: 'fin-settlement', name: 'finance-fin-settlement', component: () => import('@/views/finance/fin_settlement/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_settlement', requiresAuth: true, roles: ['*'] } }, + { path: 'fin-reconciliation', name: 'finance-fin-reconciliation', component: () => import('@/views/finance/fin_reconciliation/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_reconciliation', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/gas.ts b/frontend/platform_admin/src/router/routes/modules/gas.ts new file mode 100644 index 0000000..a314ce7 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/gas.ts @@ -0,0 +1,11 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/gas', name: 'gas', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-apps', order: 10 }, + children: [ + { path: 'gas-basic', name: 'gas-gas-basic', component: () => import('@/views/gas/gas_basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_basic', requiresAuth: true, roles: ['*'] } }, + { path: 'gas-account', name: 'gas-gas-account', component: () => import('@/views/gas/gas_account/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_account', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/notification.ts b/frontend/platform_admin/src/router/routes/modules/notification.ts new file mode 100644 index 0000000..e60b8cb --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/notification.ts @@ -0,0 +1,10 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/notification', name: 'notification', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.notification', requiresAuth: true, icon: 'icon-apps', order: 18 }, + children: [ + { path: 'ntf-template', name: 'notification-ntf-template', component: () => import('@/views/notification/ntf_template/ListPage.vue'), meta: { locale: 'menu.platform.notification.ntf_template', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index d6cbfa5..bde09a3 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -1,13 +1,24 @@ import { DEFAULT_LAYOUT } from '../base'; import type { AppRouteRecordRaw } from '../types'; - -const PLATFORM: AppRouteRecordRaw[] = [ +import gasRoutes from './gas'; +import deliveryRoutes from './delivery'; +import staffRoutes from './staff'; +import userRoutes from './user'; +import deviceRoutes from './device'; +import commerceRoutes from './commerce'; +import financeRoutes from './finance'; +import contentRoutes from './content'; +import notificationRoutes from './notification'; +import customerServiceRoutes from './customer_service'; +import walletRoutes from './wallet'; +import reportRoutes from './report'; +import auditRoutes from './audit'; +const platformRoutes: AppRouteRecordRaw[] = [ { path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, roles: ['*'] } }] }, - { path: '/gas', name: 'gas', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-fire', order: 10 }, children: [{ path: 'basic', name: 'GasBasic', component: () => import('@/views/gas/basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.basic', requiresAuth: true, roles: ['*'] } }] }, - { path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-car', order: 11 }, children: [{ path: 'basic', name: 'DeliveryBasic', component: () => import('@/views/delivery/basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.basic', requiresAuth: true, roles: ['*'] } }] }, - { path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-user', order: 12 }, children: [{ path: 'list', name: 'Staff', component: () => import('@/views/staff/list/ListPage.vue'), meta: { locale: 'menu.platform.staff.list', requiresAuth: true, roles: ['*'] } }] }, - { path: '/user', name: 'user', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-user-group', order: 13 }, children: [{ path: 'list', name: 'User', component: () => import('@/views/user/list/ListPage.vue'), meta: { locale: 'menu.platform.user.list', requiresAuth: true, roles: ['*'] } }] }, - { path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 14 }, children: [{ path: 'account', name: 'PlatformAccount', component: () => import('@/views/platform/account/ListPage.vue'), meta: { locale: 'menu.platform.config.account', requiresAuth: true, roles: ['*'] } }, { path: 'role', name: 'PlatformRole', component: () => import('@/views/platform/role/ListPage.vue'), meta: { locale: 'menu.platform.config.role', requiresAuth: true, roles: ['*'] } }, { path: 'menu', name: 'PlatformMenu', component: () => import('@/views/platform/menu/TreePage.vue'), meta: { locale: 'menu.platform.config.menu', requiresAuth: true, roles: ['*'] } }] }, + { path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 30 }, children: [ + { path: 'platfrom-account', name: 'platform-platfrom-account', component: () => import('@/views/platform/platfrom_account/ListPage.vue'), meta: { locale: 'menu.platform.platform.platfrom_account', requiresAuth: true, roles: ['*'] } }, + { path: 'platform-role', name: 'platform-platform-role', component: () => import('@/views/platform/platform_role/ListPage.vue'), meta: { locale: 'menu.platform.platform.platform_role', requiresAuth: true, roles: ['*'] } }, + { path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, roles: ['*'] } }, + ] }, ]; - -export default PLATFORM; +export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...commerceRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes]; diff --git a/frontend/platform_admin/src/router/routes/modules/report.ts b/frontend/platform_admin/src/router/routes/modules/report.ts new file mode 100644 index 0000000..8c75496 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/report.ts @@ -0,0 +1,12 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/report', name: 'report', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.report', requiresAuth: true, icon: 'icon-apps', order: 21 }, + children: [ + { path: 'report', name: 'report-report', component: () => import('@/views/report/report/ListPage.vue'), meta: { locale: 'menu.platform.report.report', requiresAuth: true, roles: ['*'] } }, + { path: 'report-item', name: 'report-report-item', component: () => import('@/views/report/report_item/ListPage.vue'), meta: { locale: 'menu.platform.report.report_item', requiresAuth: true, roles: ['*'] } }, + { path: 'report-metric-snapshot', name: 'report-report-metric-snapshot', component: () => import('@/views/report/report_metric_snapshot/ListPage.vue'), meta: { locale: 'menu.platform.report.report_metric_snapshot', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/staff.ts b/frontend/platform_admin/src/router/routes/modules/staff.ts new file mode 100644 index 0000000..56e1ac3 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/staff.ts @@ -0,0 +1,11 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-apps', order: 12 }, + children: [ + { path: 'staff-account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, roles: ['*'] } }, + { path: 'staff-credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/user.ts b/frontend/platform_admin/src/router/routes/modules/user.ts new file mode 100644 index 0000000..1c6788b --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/user.ts @@ -0,0 +1,12 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/user', name: 'user', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-apps', order: 13 }, + children: [ + { path: 'user-account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, roles: ['*'] } }, + { path: 'user-address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, roles: ['*'] } }, + { path: 'user-service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/wallet.ts b/frontend/platform_admin/src/router/routes/modules/wallet.ts new file mode 100644 index 0000000..5056ab4 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/wallet.ts @@ -0,0 +1,13 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ + path: '/wallet', name: 'wallet', component: DEFAULT_LAYOUT, + meta: { locale: 'menu.platform.wallet', requiresAuth: true, icon: 'icon-apps', order: 20 }, + children: [ + { path: 'wallet', name: 'wallet-wallet', component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet', requiresAuth: true, roles: ['*'] } }, + { path: 'wallet-ledger', name: 'wallet-wallet-ledger', component: () => import('@/views/wallet/wallet_ledger/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_ledger', requiresAuth: true, roles: ['*'] } }, + { path: 'wallet-recharge', name: 'wallet-wallet-recharge', component: () => import('@/views/wallet/wallet_recharge/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_recharge', requiresAuth: true, roles: ['*'] } }, + { path: 'wallet-withdrawal', name: 'wallet-wallet-withdrawal', component: () => import('@/views/wallet/wallet_withdrawal/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_withdrawal', requiresAuth: true, roles: ['*'] } } + ], +}]; +export default routes; diff --git a/frontend/platform_admin/src/views/audit/aud_approval/ListPage.vue b/frontend/platform_admin/src/views/audit/aud_approval/ListPage.vue new file mode 100644 index 0000000..34e8b90 --- /dev/null +++ b/frontend/platform_admin/src/views/audit/aud_approval/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/audit/aud_export_log/ListPage.vue b/frontend/platform_admin/src/views/audit/aud_export_log/ListPage.vue new file mode 100644 index 0000000..4fd02aa --- /dev/null +++ b/frontend/platform_admin/src/views/audit/aud_export_log/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/audit/aud_operation_log/ListPage.vue b/frontend/platform_admin/src/views/audit/aud_operation_log/ListPage.vue new file mode 100644 index 0000000..d47b440 --- /dev/null +++ b/frontend/platform_admin/src/views/audit/aud_operation_log/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue new file mode 100644 index 0000000..fb82532 --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue new file mode 100644 index 0000000..1b06ee1 --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue new file mode 100644 index 0000000..315a0b7 --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue new file mode 100644 index 0000000..224e6dc --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue new file mode 100644 index 0000000..ba927e1 --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue new file mode 100644 index 0000000..8b31cd4 --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue new file mode 100644 index 0000000..0752bcd --- /dev/null +++ b/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/content/cnt_content/ListPage.vue b/frontend/platform_admin/src/views/content/cnt_content/ListPage.vue new file mode 100644 index 0000000..253592c --- /dev/null +++ b/frontend/platform_admin/src/views/content/cnt_content/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/customer_service/cs_ticket/ListPage.vue b/frontend/platform_admin/src/views/customer_service/cs_ticket/ListPage.vue new file mode 100644 index 0000000..f08cc68 --- /dev/null +++ b/frontend/platform_admin/src/views/customer_service/cs_ticket/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/delivery/delivery_account/ListPage.vue b/frontend/platform_admin/src/views/delivery/delivery_account/ListPage.vue new file mode 100644 index 0000000..6cb3fd1 --- /dev/null +++ b/frontend/platform_admin/src/views/delivery/delivery_account/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/delivery/delivery_basic/ListPage.vue b/frontend/platform_admin/src/views/delivery/delivery_basic/ListPage.vue new file mode 100644 index 0000000..a8c03c6 --- /dev/null +++ b/frontend/platform_admin/src/views/delivery/delivery_basic/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/delivery/delivery_task/ListPage.vue b/frontend/platform_admin/src/views/delivery/delivery_task/ListPage.vue new file mode 100644 index 0000000..73495a1 --- /dev/null +++ b/frontend/platform_admin/src/views/delivery/delivery_task/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/delivery/delivery_track/ListPage.vue b/frontend/platform_admin/src/views/delivery/delivery_track/ListPage.vue new file mode 100644 index 0000000..e525799 --- /dev/null +++ b/frontend/platform_admin/src/views/delivery/delivery_track/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/delivery/delivery_track_point/ListPage.vue b/frontend/platform_admin/src/views/delivery/delivery_track_point/ListPage.vue new file mode 100644 index 0000000..c7bc253 --- /dev/null +++ b/frontend/platform_admin/src/views/delivery/delivery_track_point/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/dev_device_binding/ListPage.vue b/frontend/platform_admin/src/views/device/dev_device_binding/ListPage.vue new file mode 100644 index 0000000..65d0e05 --- /dev/null +++ b/frontend/platform_admin/src/views/device/dev_device_binding/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/dev_smart_cylinder_valve/ListPage.vue b/frontend/platform_admin/src/views/device/dev_smart_cylinder_valve/ListPage.vue new file mode 100644 index 0000000..7600835 --- /dev/null +++ b/frontend/platform_admin/src/views/device/dev_smart_cylinder_valve/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/dev_telemetry/ListPage.vue b/frontend/platform_admin/src/views/device/dev_telemetry/ListPage.vue new file mode 100644 index 0000000..6051038 --- /dev/null +++ b/frontend/platform_admin/src/views/device/dev_telemetry/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/saf_event/ListPage.vue b/frontend/platform_admin/src/views/device/saf_event/ListPage.vue new file mode 100644 index 0000000..d09abe1 --- /dev/null +++ b/frontend/platform_admin/src/views/device/saf_event/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue b/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue new file mode 100644 index 0000000..9de65c9 --- /dev/null +++ b/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue b/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue new file mode 100644 index 0000000..3d198cb --- /dev/null +++ b/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/device/saf_rule/ListPage.vue b/frontend/platform_admin/src/views/device/saf_rule/ListPage.vue new file mode 100644 index 0000000..52f5708 --- /dev/null +++ b/frontend/platform_admin/src/views/device/saf_rule/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/finance/fin_payment/ListPage.vue b/frontend/platform_admin/src/views/finance/fin_payment/ListPage.vue new file mode 100644 index 0000000..8eb9ff5 --- /dev/null +++ b/frontend/platform_admin/src/views/finance/fin_payment/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/finance/fin_reconciliation/ListPage.vue b/frontend/platform_admin/src/views/finance/fin_reconciliation/ListPage.vue new file mode 100644 index 0000000..d67a38d --- /dev/null +++ b/frontend/platform_admin/src/views/finance/fin_reconciliation/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/finance/fin_settlement/ListPage.vue b/frontend/platform_admin/src/views/finance/fin_settlement/ListPage.vue new file mode 100644 index 0000000..f316330 --- /dev/null +++ b/frontend/platform_admin/src/views/finance/fin_settlement/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/gas/gas_account/ListPage.vue b/frontend/platform_admin/src/views/gas/gas_account/ListPage.vue new file mode 100644 index 0000000..13ac838 --- /dev/null +++ b/frontend/platform_admin/src/views/gas/gas_account/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/gas/gas_basic/ListPage.vue b/frontend/platform_admin/src/views/gas/gas_basic/ListPage.vue new file mode 100644 index 0000000..2eae051 --- /dev/null +++ b/frontend/platform_admin/src/views/gas/gas_basic/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/notification/ntf_template/ListPage.vue b/frontend/platform_admin/src/views/notification/ntf_template/ListPage.vue new file mode 100644 index 0000000..e63f989 --- /dev/null +++ b/frontend/platform_admin/src/views/notification/ntf_template/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/platform/menu/TreePage.vue b/frontend/platform_admin/src/views/platform/menu/TreePage.vue index 1c9b820..10bbcd9 100644 --- a/frontend/platform_admin/src/views/platform/menu/TreePage.vue +++ b/frontend/platform_admin/src/views/platform/menu/TreePage.vue @@ -1 +1,6 @@ - + + diff --git a/frontend/platform_admin/src/views/platform/platform_menu/TreePage.vue b/frontend/platform_admin/src/views/platform/platform_menu/TreePage.vue new file mode 100644 index 0000000..10bbcd9 --- /dev/null +++ b/frontend/platform_admin/src/views/platform/platform_menu/TreePage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/platform/platform_role/ListPage.vue b/frontend/platform_admin/src/views/platform/platform_role/ListPage.vue new file mode 100644 index 0000000..f70a72c --- /dev/null +++ b/frontend/platform_admin/src/views/platform/platform_role/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/platform/platfrom_account/ListPage.vue b/frontend/platform_admin/src/views/platform/platfrom_account/ListPage.vue new file mode 100644 index 0000000..1695448 --- /dev/null +++ b/frontend/platform_admin/src/views/platform/platfrom_account/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/report/report/ListPage.vue b/frontend/platform_admin/src/views/report/report/ListPage.vue new file mode 100644 index 0000000..7070cb1 --- /dev/null +++ b/frontend/platform_admin/src/views/report/report/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/report/report_item/ListPage.vue b/frontend/platform_admin/src/views/report/report_item/ListPage.vue new file mode 100644 index 0000000..9142729 --- /dev/null +++ b/frontend/platform_admin/src/views/report/report_item/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/report/report_metric_snapshot/ListPage.vue b/frontend/platform_admin/src/views/report/report_metric_snapshot/ListPage.vue new file mode 100644 index 0000000..90768f1 --- /dev/null +++ b/frontend/platform_admin/src/views/report/report_metric_snapshot/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index d7f2939..9cf6ddd 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -1,40 +1,49 @@ - + diff --git a/frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue b/frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue new file mode 100644 index 0000000..0d318b9 --- /dev/null +++ b/frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/shared/TreePage.vue b/frontend/platform_admin/src/views/shared/TreePage.vue new file mode 100644 index 0000000..c82e1a6 --- /dev/null +++ b/frontend/platform_admin/src/views/shared/TreePage.vue @@ -0,0 +1,24 @@ + + diff --git a/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue b/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue new file mode 100644 index 0000000..6db1c6b --- /dev/null +++ b/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue b/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue new file mode 100644 index 0000000..ac1d4ad --- /dev/null +++ b/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/user/user_account/ListPage.vue b/frontend/platform_admin/src/views/user/user_account/ListPage.vue new file mode 100644 index 0000000..70dab72 --- /dev/null +++ b/frontend/platform_admin/src/views/user/user_account/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/user/user_address/ListPage.vue b/frontend/platform_admin/src/views/user/user_address/ListPage.vue new file mode 100644 index 0000000..a17548a --- /dev/null +++ b/frontend/platform_admin/src/views/user/user_address/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue b/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue new file mode 100644 index 0000000..461c15a --- /dev/null +++ b/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/wallet/wallet/ListPage.vue b/frontend/platform_admin/src/views/wallet/wallet/ListPage.vue new file mode 100644 index 0000000..923935e --- /dev/null +++ b/frontend/platform_admin/src/views/wallet/wallet/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/wallet/wallet_ledger/ListPage.vue b/frontend/platform_admin/src/views/wallet/wallet_ledger/ListPage.vue new file mode 100644 index 0000000..f93d19f --- /dev/null +++ b/frontend/platform_admin/src/views/wallet/wallet_ledger/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/wallet/wallet_recharge/ListPage.vue b/frontend/platform_admin/src/views/wallet/wallet_recharge/ListPage.vue new file mode 100644 index 0000000..80911e9 --- /dev/null +++ b/frontend/platform_admin/src/views/wallet/wallet_recharge/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/wallet/wallet_withdrawal/ListPage.vue b/frontend/platform_admin/src/views/wallet/wallet_withdrawal/ListPage.vue new file mode 100644 index 0000000..e987d4b --- /dev/null +++ b/frontend/platform_admin/src/views/wallet/wallet_withdrawal/ListPage.vue @@ -0,0 +1,6 @@ + + From 10cd2783e15c2d67e52e7e9c46941e74233bbe56 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 09:44:08 +0800 Subject: [PATCH 06/17] fix: align platform admin resource paths --- .../task-6-report.md | 7 + .../platform_admin/scripts/audit-check.mjs | 126 +++++++----------- frontend/platform_admin/src/api/resources.ts | 77 +++++++---- frontend/platform_admin/src/api/staff.ts | 2 +- frontend/platform_admin/src/api/user.ts | 2 +- .../src/router/routes/modules/commerce.ts | 16 --- .../src/router/routes/modules/device.ts | 6 +- .../src/router/routes/modules/ec.ts | 13 ++ .../src/router/routes/modules/platform.ts | 5 +- .../src/router/routes/modules/safety.ts | 8 ++ .../src/router/routes/modules/staff.ts | 4 +- .../src/router/routes/modules/user.ts | 6 +- .../ec_product_attribute/ListPage.vue | 6 - .../commerce/ec_product_image/ListPage.vue | 6 - .../src/views/commerce/ec_review/ListPage.vue | 6 - .../device/saf_event_disposal/ListPage.vue | 6 - .../views/device/saf_inspection/ListPage.vue | 6 - .../saf_rule => ec/ec_cart}/ListPage.vue | 2 +- .../saf_event => ec/ec_category}/ListPage.vue | 2 +- .../ec_cart => ec/ec_order}/ListPage.vue | 2 +- .../ec_order_item}/ListPage.vue | 2 +- .../src/views/ec/ec_product/ListPage.vue | 6 + .../ec/ec_product_attribute/ListPage.vue | 6 + .../ec_product_image}/ListPage.vue | 2 +- .../src/views/ec/ec_review/ListPage.vue | 6 + .../src/views/safety/saf_event/ListPage.vue | 6 + .../saf_inspection}/ListPage.vue | 2 +- .../src/views/safety/saf_rule/ListPage.vue | 6 + .../src/views/shared/CrudListPage.vue | 30 +---- .../src/views/shared/ReadOnlyListPage.vue | 25 +++- .../src/views/staff/list/ListPage.vue | 7 +- .../views/staff/staff_account/ListPage.vue | 2 +- .../views/staff/staff_credential/ListPage.vue | 2 +- .../src/views/user/list/ListPage.vue | 7 +- .../src/views/user/user_account/ListPage.vue | 2 +- .../src/views/user/user_address/ListPage.vue | 2 +- .../user/user_service_relation/ListPage.vue | 2 +- 37 files changed, 215 insertions(+), 208 deletions(-) delete mode 100644 frontend/platform_admin/src/router/routes/modules/commerce.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/ec.ts create mode 100644 frontend/platform_admin/src/router/routes/modules/safety.ts delete mode 100644 frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue delete mode 100644 frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue delete mode 100644 frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue delete mode 100644 frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue delete mode 100644 frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue rename frontend/platform_admin/src/views/{device/saf_rule => ec/ec_cart}/ListPage.vue (79%) rename frontend/platform_admin/src/views/{device/saf_event => ec/ec_category}/ListPage.vue (79%) rename frontend/platform_admin/src/views/{commerce/ec_cart => ec/ec_order}/ListPage.vue (79%) rename frontend/platform_admin/src/views/{commerce/ec_order => ec/ec_order_item}/ListPage.vue (79%) create mode 100644 frontend/platform_admin/src/views/ec/ec_product/ListPage.vue create mode 100644 frontend/platform_admin/src/views/ec/ec_product_attribute/ListPage.vue rename frontend/platform_admin/src/views/{commerce/ec_product => ec/ec_product_image}/ListPage.vue (78%) create mode 100644 frontend/platform_admin/src/views/ec/ec_review/ListPage.vue create mode 100644 frontend/platform_admin/src/views/safety/saf_event/ListPage.vue rename frontend/platform_admin/src/views/{commerce/ec_category => safety/saf_inspection}/ListPage.vue (77%) create mode 100644 frontend/platform_admin/src/views/safety/saf_rule/ListPage.vue diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md index aba0882..ffbd95d 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -21,3 +21,10 @@ ## Notes The tree page consumes `parent_identity`; the corresponding menu API must expose that identity relation rather than an internal numeric parent key. + +## Fix round 1 + +- Replaced inferred front-end paths with an explicit 46-path catalogue: it includes the dashboard endpoint, adds `/ec/ec_order_item`, and removes the non-list safety disposal action from the menu catalogue. +- Corrected the safety, e-commerce, staff, and user API paths to match the registered backend routes exactly. +- Added audit assertions for exact path/mode/page-kind contracts, separate read-only UI surfaces, identifier leakage, and tree semantics. +- Detail drawers now filter `id` and `*_id` fields before rendering. diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 4050e7d..24c0b73 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,90 +1,54 @@ import fs from 'node:fs'; import path from 'node:path'; -const baseline = process.argv.includes('--baseline'); - -const expectedResources = [ - ['gas', 'gas_basic', 'list'], ['gas', 'gas_account', 'list'], - ['delivery', 'delivery_basic', 'list'], ['delivery', 'delivery_account', 'list'], ['delivery', 'delivery_task', 'list'], ['delivery', 'delivery_track', 'list'], ['delivery', 'delivery_track_point', 'list'], - ['staff', 'staff_account', 'list'], ['staff', 'staff_credential', 'list'], - ['user', 'user_account', 'list'], ['user', 'user_address', 'list'], ['user', 'user_service_relation', 'list'], - ['device', 'dev_smart_cylinder_valve', 'list'], ['device', 'dev_device_binding', 'list'], ['device', 'dev_telemetry', 'list'], ['device', 'saf_rule', 'list'], ['device', 'saf_event', 'list'], ['device', 'saf_event_disposal', 'list'], ['device', 'saf_inspection', 'list'], - ['commerce', 'ec_category', 'list'], ['commerce', 'ec_product', 'list'], ['commerce', 'ec_product_attribute', 'list'], ['commerce', 'ec_product_image', 'list'], ['commerce', 'ec_cart', 'list'], ['commerce', 'ec_order', 'list'], ['commerce', 'ec_review', 'list'], - ['finance', 'fin_payment', 'list'], ['finance', 'fin_settlement', 'list'], ['finance', 'fin_reconciliation', 'list'], - ['content', 'cnt_content', 'list'], ['notification', 'ntf_template', 'list'], ['customer_service', 'cs_ticket', 'list'], - ['platform', 'platfrom_account', 'list'], ['platform', 'platform_role', 'list'], ['platform', 'platform_menu', 'tree'], - ['wallet', 'wallet', 'list'], ['wallet', 'wallet_ledger', 'list'], ['wallet', 'wallet_recharge', 'list'], ['wallet', 'wallet_withdrawal', 'list'], - ['report', 'report', 'list'], ['report', 'report_item', 'list'], ['report', 'report_metric_snapshot', 'list'], - ['audit', 'aud_operation_log', 'list'], ['audit', 'aud_export_log', 'list'], ['audit', 'aud_approval', 'list'], +const contracts = [ + ['dashboard', 'overview', '/dashboard/overview', 'readonly', 'dashboard'], + ['gas', 'gas_basic', '/gas/gas_basic', 'writable', 'list'], ['gas', 'gas_account', '/gas/gas_account', 'writable', 'list'], + ['delivery', 'delivery_basic', '/delivery/delivery_basic', 'writable', 'list'], ['delivery', 'delivery_account', '/delivery/delivery_account', 'writable', 'list'], ['delivery', 'delivery_task', '/delivery/delivery_task', 'writable', 'list'], ['delivery', 'delivery_track', '/delivery/delivery_track', 'writable', 'list'], ['delivery', 'delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list'], + ['staff', 'staff_account', '/staff/account', 'writable', 'list'], ['staff', 'staff_credential', '/staff/credential', 'writable', 'list'], + ['user', 'user_account', '/user/account', 'writable', 'list'], ['user', 'user_address', '/user/address', 'writable', 'list'], ['user', 'user_service_relation', '/user/service_relation', 'writable', 'list'], + ['device', 'dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list'], ['device', 'dev_device_binding', '/device/dev_device_binding', 'writable', 'list'], ['device', 'dev_telemetry', '/device/dev_telemetry', 'readonly', 'list'], + ['safety', 'saf_rule', '/safety/saf_rule', 'writable', 'list'], ['safety', 'saf_event', '/safety/saf_event', 'writable', 'list'], ['safety', 'saf_inspection', '/safety/saf_inspection', 'writable', 'list'], + ['ec', 'ec_category', '/ec/ec_category', 'writable', 'list'], ['ec', 'ec_product', '/ec/ec_product', 'writable', 'list'], ['ec', 'ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list'], ['ec', 'ec_product_image', '/ec/ec_product_image', 'writable', 'list'], ['ec', 'ec_cart', '/ec/ec_cart', 'writable', 'list'], ['ec', 'ec_order', '/ec/ec_order', 'writable', 'list'], ['ec', 'ec_order_item', '/ec/ec_order_item', 'writable', 'list'], ['ec', 'ec_review', '/ec/ec_review', 'writable', 'list'], + ['finance', 'fin_payment', '/finance/fin_payment', 'writable', 'list'], ['finance', 'fin_settlement', '/finance/fin_settlement', 'writable', 'list'], ['finance', 'fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list'], + ['content', 'cnt_content', '/content/cnt_content', 'writable', 'list'], ['notification', 'ntf_template', '/notification/ntf_template', 'writable', 'list'], ['customer_service', 'cs_ticket', '/customer_service/cs_ticket', 'writable', 'list'], + ['platform', 'platfrom_account', '/platform/platfrom_account', 'writable', 'list'], ['platform', 'platform_role', '/platform/platform_role', 'writable', 'list'], ['platform', 'platform_menu', '/platform/platform_menu', 'writable', 'tree'], + ['wallet', 'wallet', '/wallet/wallet', 'readonly', 'list'], ['wallet', 'wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list'], ['wallet', 'wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list'], ['wallet', 'wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list'], + ['report', 'report', '/report/report', 'readonly', 'list'], ['report', 'report_item', '/report/report_item', 'readonly', 'list'], ['report', 'report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list'], + ['audit', 'aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list'], ['audit', 'aud_export_log', '/audit/aud_export_log', 'readonly', 'list'], ['audit', 'aud_approval', '/audit/aud_approval', 'readonly', 'list'], ]; -function fileIncludes(file, value) { - return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(value); -} +const resourceSource = fs.readFileSync('src/api/resources.ts', 'utf8'); +const resourceEntry = (name, resource, mode, pageKind) => new RegExp(`name: '${name}', resource: '${resource}', title: '[^']+', mode: '${mode}', pageKind: '${pageKind}'`); +const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : []; -function checkResourceLayers() { - const missing = []; - for (const [domain, name, pageKind] of expectedResources) { - if (!fileIncludes('src/api/resources.ts', name)) missing.push(`${domain}/${name}: missing api`); - if (!fileIncludes(path.join('src/router/routes/modules', `${domain}.ts`), name)) missing.push(`${domain}/${name}: missing route`); - const page = pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'; - if (!fs.existsSync(path.join('src/views', domain, name, page))) missing.push(`${domain}/${name}: missing view`); +function checkContracts() { + const failures = []; + if (contracts.length !== 46) failures.push(`catalogue: expected 46 resources, got ${contracts.length}`); + for (const [domain, name, resource, mode, pageKind] of contracts) { + const label = `${domain}/${name}`; + if (!resourceEntry(name, resource, mode, pageKind).test(resourceSource)) failures.push(`${label}: missing or incorrect api contract`); + const route = path.join('src/router/routes/modules', domain === 'dashboard' ? 'platform.ts' : `${domain}.ts`); + if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(name)) failures.push(`${label}: missing route`); + const page = pageKind === 'dashboard' ? path.join('src/views/dashboard/DashboardPage.vue') : path.join('src/views', domain, name, pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); + if (!fs.existsSync(page)) failures.push(`${label}: missing view`); + if (mode === 'readonly' && pageKind !== 'dashboard' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); } - return missing; + const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); + if (/(CrudListPage|resourceApi\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); + const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); + if (!treePage.includes(' /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); + if (identityLeaks.length) failures.push(`identity leak: ${identityLeaks.join(', ')}`); + if (resourceSource.includes('saf_event_disposal') || files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal'))) failures.push('saf_event_disposal: must not be displayed as an independent resource'); + const obsoletePaths = /\/(?:commerce|staff\/staff_|user\/user_)/; + const stalePathFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && obsoletePaths.test(fs.readFileSync(file, 'utf8'))); + if (stalePathFiles.length) failures.push(`obsolete resource path: ${stalePathFiles.join(', ')}`); + return failures; } -function walk(dir, acc = []) { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory() && entry.name !== 'node_modules') { - walk(full, acc); - } else if (/\.(ts|vue)$/.test(entry.name)) { - acc.push(full); - } - } - return acc; -} - -const srcFiles = walk('src'); -const badPlaceholder = []; -const zhLocales = srcFiles.filter((f) => f.includes(`${path.sep}locale${path.sep}zh-CN.ts`)); - -for (const file of srcFiles) { - const text = fs.readFileSync(file, 'utf8'); - if (text.includes("'???'") || /'(\?\?[^']*)'/.test(text)) { - badPlaceholder.push(file); - } -} - -let zhOk = 0; -for (const file of zhLocales) { - if (/[\u4e00-\u9fff]/.test(fs.readFileSync(file, 'utf8'))) zhOk += 1; -} - -let mockInDist = false; -if (fs.existsSync('dist/assets')) { - for (const name of fs.readdirSync('dist/assets')) { - if (!name.endsWith('.js')) continue; - const chunk = fs.readFileSync(path.join('dist/assets', name), 'utf8'); - if (chunk.includes('mockjs') || chunk.includes('Mock.mock')) { - mockInDist = true; - break; - } - } -} - -console.log(JSON.stringify({ - badPlaceholder: badPlaceholder.length, - zhLocales: `${zhOk}/${zhLocales.length}`, - mockInDist, - hasGit: fs.existsSync('.env.development') && fs.readFileSync('.env.development', 'utf8').includes('VITE_API_BASE_URL=http'), - settingsHttp: fs.readFileSync('src/locale/zh-CN/settings.ts', 'utf8').includes('http.logout.title'), - rootMenu: fs.readFileSync('src/locale/zh-CN.ts', 'utf8').includes('仪表盘'), -}, null, 2)); - -const missingLayers = checkResourceLayers(); -for (const missing of missingLayers) console.log(missing); - -if (missingLayers.length > 0 && !baseline) { - process.exitCode = 1; -} +const failures = checkContracts(); +for (const failure of failures) console.log(failure); +if (failures.length) process.exitCode = 1; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index c096ad0..82b0da1 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -1,39 +1,60 @@ export type ResourceMode = 'writable' | 'readonly' | 'append_only'; export type ResourcePageKind = 'list' | 'tree' | 'dashboard'; export type ResourceField = { key: string; label: string; required?: boolean }; -export type ResourceUiDefinition = { - key: string; - resource: string; - title: string; - mode: ResourceMode; - pageKind: ResourcePageKind; - fields: ResourceField[]; -}; - +export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[] }; const fields: ResourceField[] = [{ key: 'name', label: '名称' }, { key: 'status', label: '状态' }]; -const resource = (domain: string, name: string, title: string, mode: ResourceMode = 'writable', pageKind: ResourcePageKind = 'list'): ResourceUiDefinition => ({ - key: name.replace(/_/g, '-'), resource: `/${domain}/${name}`, title, mode, pageKind, fields, -}); - -/** The single UI catalogue mirrors the backend ExpectedResources contract. */ +/** Exact front-end representation of the platform HTTP resource contract. */ export const resources: ResourceUiDefinition[] = [ - resource('gas', 'gas_basic', '气站管理'), resource('gas', 'gas_account', '气站账户'), - resource('delivery', 'delivery_basic', '配送点管理'), resource('delivery', 'delivery_account', '配送账户'), resource('delivery', 'delivery_task', '配送任务'), resource('delivery', 'delivery_track', '配送轨迹'), resource('delivery', 'delivery_track_point', '轨迹点'), - resource('staff', 'staff_account', '服务人员'), resource('staff', 'staff_credential', '人员资质'), - resource('user', 'user_account', '用户账户'), resource('user', 'user_address', '用户地址'), resource('user', 'user_service_relation', '用户服务关系'), - resource('device', 'dev_smart_cylinder_valve', '智能钢瓶阀'), resource('device', 'dev_device_binding', '设备绑定'), resource('device', 'dev_telemetry', '设备遥测', 'readonly'), resource('device', 'saf_rule', '安全规则'), resource('device', 'saf_event', '安全事件'), resource('device', 'saf_event_disposal', '事件处置记录', 'append_only'), resource('device', 'saf_inspection', '安全检查'), - resource('commerce', 'ec_category', '商品分类'), resource('commerce', 'ec_product', '商品'), resource('commerce', 'ec_product_attribute', '商品属性'), resource('commerce', 'ec_product_image', '商品图片'), resource('commerce', 'ec_cart', '购物车'), resource('commerce', 'ec_order', '订单'), resource('commerce', 'ec_review', '评价'), - resource('finance', 'fin_payment', '支付记录'), resource('finance', 'fin_settlement', '结算记录'), resource('finance', 'fin_reconciliation', '对账记录'), - resource('content', 'cnt_content', '内容管理'), resource('notification', 'ntf_template', '通知模板'), resource('customer_service', 'cs_ticket', '客服工单'), - resource('platform', 'platfrom_account', '平台账户'), resource('platform', 'platform_role', '平台角色'), resource('platform', 'platform_menu', '平台菜单', 'writable', 'tree'), - resource('wallet', 'wallet', '钱包', 'readonly'), resource('wallet', 'wallet_ledger', '钱包流水', 'readonly'), resource('wallet', 'wallet_recharge', '钱包充值', 'readonly'), resource('wallet', 'wallet_withdrawal', '钱包提现', 'readonly'), - resource('report', 'report', '报表', 'readonly'), resource('report', 'report_item', '报表项目', 'readonly'), resource('report', 'report_metric_snapshot', '指标快照', 'readonly'), - resource('audit', 'aud_operation_log', '操作审计', 'readonly'), resource('audit', 'aud_export_log', '导出审计', 'readonly'), resource('audit', 'aud_approval', '审批审计', 'readonly'), + { key: 'overview', name: 'overview', resource: '/dashboard/overview', title: 'overview', mode: 'readonly', pageKind: 'dashboard', fields }, + { key: 'gas-basic', name: 'gas_basic', resource: '/gas/gas_basic', title: 'gas_basic', mode: 'writable', pageKind: 'list', fields }, + { key: 'gas-account', name: 'gas_account', resource: '/gas/gas_account', title: 'gas_account', mode: 'writable', pageKind: 'list', fields }, + { key: 'delivery-basic', name: 'delivery_basic', resource: '/delivery/delivery_basic', title: 'delivery_basic', mode: 'writable', pageKind: 'list', fields }, + { key: 'delivery-account', name: 'delivery_account', resource: '/delivery/delivery_account', title: 'delivery_account', mode: 'writable', pageKind: 'list', fields }, + { key: 'delivery-task', name: 'delivery_task', resource: '/delivery/delivery_task', title: 'delivery_task', mode: 'writable', pageKind: 'list', fields }, + { key: 'delivery-track', name: 'delivery_track', resource: '/delivery/delivery_track', title: 'delivery_track', mode: 'writable', pageKind: 'list', fields }, + { key: 'delivery-track-point', name: 'delivery_track_point', resource: '/delivery/delivery_track_point', title: 'delivery_track_point', mode: 'writable', pageKind: 'list', fields }, + { key: 'staff-account', name: 'staff_account', resource: '/staff/account', title: 'staff_account', mode: 'writable', pageKind: 'list', fields }, + { key: 'staff-credential', name: 'staff_credential', resource: '/staff/credential', title: 'staff_credential', mode: 'writable', pageKind: 'list', fields }, + { key: 'user-account', name: 'user_account', resource: '/user/account', title: 'user_account', mode: 'writable', pageKind: 'list', fields }, + { key: 'user-address', name: 'user_address', resource: '/user/address', title: 'user_address', mode: 'writable', pageKind: 'list', fields }, + { key: 'user-service-relation', name: 'user_service_relation', resource: '/user/service_relation', title: 'user_service_relation', mode: 'writable', pageKind: 'list', fields }, + { key: 'dev-smart-cylinder-valve', name: 'dev_smart_cylinder_valve', resource: '/device/dev_smart_cylinder_valve', title: 'dev_smart_cylinder_valve', mode: 'writable', pageKind: 'list', fields }, + { key: 'dev-device-binding', name: 'dev_device_binding', resource: '/device/dev_device_binding', title: 'dev_device_binding', mode: 'writable', pageKind: 'list', fields }, + { key: 'dev-telemetry', name: 'dev_telemetry', resource: '/device/dev_telemetry', title: 'dev_telemetry', mode: 'readonly', pageKind: 'list', fields }, + { key: 'saf-rule', name: 'saf_rule', resource: '/safety/saf_rule', title: 'saf_rule', mode: 'writable', pageKind: 'list', fields }, + { key: 'saf-event', name: 'saf_event', resource: '/safety/saf_event', title: 'saf_event', mode: 'writable', pageKind: 'list', fields }, + { key: 'saf-inspection', name: 'saf_inspection', resource: '/safety/saf_inspection', title: 'saf_inspection', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-category', name: 'ec_category', resource: '/ec/ec_category', title: 'ec_category', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-product', name: 'ec_product', resource: '/ec/ec_product', title: 'ec_product', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-product-attribute', name: 'ec_product_attribute', resource: '/ec/ec_product_attribute', title: 'ec_product_attribute', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-product-image', name: 'ec_product_image', resource: '/ec/ec_product_image', title: 'ec_product_image', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-cart', name: 'ec_cart', resource: '/ec/ec_cart', title: 'ec_cart', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-order', name: 'ec_order', resource: '/ec/ec_order', title: 'ec_order', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-order-item', name: 'ec_order_item', resource: '/ec/ec_order_item', title: 'ec_order_item', mode: 'writable', pageKind: 'list', fields }, + { key: 'ec-review', name: 'ec_review', resource: '/ec/ec_review', title: 'ec_review', mode: 'writable', pageKind: 'list', fields }, + { key: 'fin-payment', name: 'fin_payment', resource: '/finance/fin_payment', title: 'fin_payment', mode: 'writable', pageKind: 'list', fields }, + { key: 'fin-settlement', name: 'fin_settlement', resource: '/finance/fin_settlement', title: 'fin_settlement', mode: 'writable', pageKind: 'list', fields }, + { key: 'fin-reconciliation', name: 'fin_reconciliation', resource: '/finance/fin_reconciliation', title: 'fin_reconciliation', mode: 'writable', pageKind: 'list', fields }, + { key: 'cnt-content', name: 'cnt_content', resource: '/content/cnt_content', title: 'cnt_content', mode: 'writable', pageKind: 'list', fields }, + { key: 'ntf-template', name: 'ntf_template', resource: '/notification/ntf_template', title: 'ntf_template', mode: 'writable', pageKind: 'list', fields }, + { key: 'cs-ticket', name: 'cs_ticket', resource: '/customer_service/cs_ticket', title: 'cs_ticket', mode: 'writable', pageKind: 'list', fields }, + { key: 'platfrom-account', name: 'platfrom_account', resource: '/platform/platfrom_account', title: 'platfrom_account', mode: 'writable', pageKind: 'list', fields }, + { key: 'platform-role', name: 'platform_role', resource: '/platform/platform_role', title: 'platform_role', mode: 'writable', pageKind: 'list', fields }, + { key: 'platform-menu', name: 'platform_menu', resource: '/platform/platform_menu', title: 'platform_menu', mode: 'writable', pageKind: 'tree', fields }, + { key: 'wallet', name: 'wallet', resource: '/wallet/wallet', title: 'wallet', mode: 'readonly', pageKind: 'list', fields }, + { key: 'wallet-ledger', name: 'wallet_ledger', resource: '/wallet/wallet_ledger', title: 'wallet_ledger', mode: 'readonly', pageKind: 'list', fields }, + { key: 'wallet-recharge', name: 'wallet_recharge', resource: '/wallet/wallet_recharge', title: 'wallet_recharge', mode: 'readonly', pageKind: 'list', fields }, + { key: 'wallet-withdrawal', name: 'wallet_withdrawal', resource: '/wallet/wallet_withdrawal', title: 'wallet_withdrawal', mode: 'readonly', pageKind: 'list', fields }, + { key: 'report', name: 'report', resource: '/report/report', title: 'report', mode: 'readonly', pageKind: 'list', fields }, + { key: 'report-item', name: 'report_item', resource: '/report/report_item', title: 'report_item', mode: 'readonly', pageKind: 'list', fields }, + { key: 'report-metric-snapshot', name: 'report_metric_snapshot', resource: '/report/report_metric_snapshot', title: 'report_metric_snapshot', mode: 'readonly', pageKind: 'list', fields }, + { key: 'aud-operation-log', name: 'aud_operation_log', resource: '/audit/aud_operation_log', title: 'aud_operation_log', mode: 'readonly', pageKind: 'list', fields }, + { key: 'aud-export-log', name: 'aud_export_log', resource: '/audit/aud_export_log', title: 'aud_export_log', mode: 'readonly', pageKind: 'list', fields }, + { key: 'aud-approval', name: 'aud_approval', resource: '/audit/aud_approval', title: 'aud_approval', mode: 'readonly', pageKind: 'list', fields }, ]; - export const resourceByName = Object.fromEntries(resources.map((definition) => [definition.resource, definition])) as Record; export function getResource(resourcePath: string): ResourceUiDefinition { const definition = resourceByName[resourcePath]; - if (!definition) throw new Error(`Unknown resource: ${resourcePath}`); + if (!definition) throw new Error('Unknown resource: ' + resourcePath); return definition; } diff --git a/frontend/platform_admin/src/api/staff.ts b/frontend/platform_admin/src/api/staff.ts index d8de0ac..c78a339 100644 --- a/frontend/platform_admin/src/api/staff.ts +++ b/frontend/platform_admin/src/api/staff.ts @@ -2,4 +2,4 @@ import { resourceApi } from './resource'; /** 服务人员管理接口。 */ export type Staff = { identity: string; name: string; phone: string; role_code: string; work_status: string; status: string }; -export const staffApi = { list: () => resourceApi.list('/staff/staff_account'), create: (data: Record) => resourceApi.create('/staff/staff_account', data) }; +export const staffApi = { list: () => resourceApi.list('/staff/account'), create: (data: Record) => resourceApi.create('/staff/account', data) }; diff --git a/frontend/platform_admin/src/api/user.ts b/frontend/platform_admin/src/api/user.ts index 9fea3d0..9d7a521 100644 --- a/frontend/platform_admin/src/api/user.ts +++ b/frontend/platform_admin/src/api/user.ts @@ -2,4 +2,4 @@ import { resourceApi } from './resource'; /** 业主客户管理接口。 */ export type User = { identity: string; name: string; phone: string; real_name: string; status: string }; -export const userApi = { list: () => resourceApi.list('/user/user_account'), create: (data: Record) => resourceApi.create('/user/user_account', data) }; +export const userApi = { list: () => resourceApi.list('/user/account'), create: (data: Record) => resourceApi.create('/user/account', data) }; diff --git a/frontend/platform_admin/src/router/routes/modules/commerce.ts b/frontend/platform_admin/src/router/routes/modules/commerce.ts deleted file mode 100644 index 128f09b..0000000 --- a/frontend/platform_admin/src/router/routes/modules/commerce.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DEFAULT_LAYOUT } from '../base'; -import type { AppRouteRecordRaw } from '../types'; -const routes: AppRouteRecordRaw[] = [{ - path: '/commerce', name: 'commerce', component: DEFAULT_LAYOUT, - meta: { locale: 'menu.platform.commerce', requiresAuth: true, icon: 'icon-apps', order: 15 }, - children: [ - { path: 'ec-category', name: 'commerce-ec-category', component: () => import('@/views/commerce/ec_category/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_category', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product', name: 'commerce-ec-product', component: () => import('@/views/commerce/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product-attribute', name: 'commerce-ec-product-attribute', component: () => import('@/views/commerce/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product_attribute', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-product-image', name: 'commerce-ec-product-image', component: () => import('@/views/commerce/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_product_image', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-cart', name: 'commerce-ec-cart', component: () => import('@/views/commerce/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_cart', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-order', name: 'commerce-ec-order', component: () => import('@/views/commerce/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_order', requiresAuth: true, roles: ['*'] } }, - { path: 'ec-review', name: 'commerce-ec-review', component: () => import('@/views/commerce/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.commerce.ec_review', requiresAuth: true, roles: ['*'] } } - ], -}]; -export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/device.ts b/frontend/platform_admin/src/router/routes/modules/device.ts index 2198db9..ec5f922 100644 --- a/frontend/platform_admin/src/router/routes/modules/device.ts +++ b/frontend/platform_admin/src/router/routes/modules/device.ts @@ -6,11 +6,7 @@ const routes: AppRouteRecordRaw[] = [{ children: [ { path: 'dev-smart-cylinder-valve', name: 'device-dev-smart-cylinder-valve', component: () => import('@/views/device/dev_smart_cylinder_valve/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_smart_cylinder_valve', requiresAuth: true, roles: ['*'] } }, { path: 'dev-device-binding', name: 'device-dev-device-binding', component: () => import('@/views/device/dev_device_binding/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_device_binding', requiresAuth: true, roles: ['*'] } }, - { path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-rule', name: 'device-saf-rule', component: () => import('@/views/device/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_rule', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-event', name: 'device-saf-event', component: () => import('@/views/device/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_event', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-event-disposal', name: 'device-saf-event-disposal', component: () => import('@/views/device/saf_event_disposal/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_event_disposal', requiresAuth: true, roles: ['*'] } }, - { path: 'saf-inspection', name: 'device-saf-inspection', component: () => import('@/views/device/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.device.saf_inspection', requiresAuth: true, roles: ['*'] } } + { path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, roles: ['*'] } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/ec.ts b/frontend/platform_admin/src/router/routes/modules/ec.ts new file mode 100644 index 0000000..835ed68 --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/ec.ts @@ -0,0 +1,13 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ path: '/ec', name: 'ec', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.ec', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [ + { path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-cart', name: 'ec-ec-cart', component: () => import('@/views/ec/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_cart', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-order', name: 'ec-ec-order', component: () => import('@/views/ec/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-order-item', name: 'ec-ec-order-item', component: () => import('@/views/ec/ec_order_item/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order_item', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-review', name: 'ec-ec-review', component: () => import('@/views/ec/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_review', requiresAuth: true, roles: ['*'] } } +] }]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index bde09a3..b58b176 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -5,7 +5,8 @@ import deliveryRoutes from './delivery'; import staffRoutes from './staff'; import userRoutes from './user'; import deviceRoutes from './device'; -import commerceRoutes from './commerce'; +import safetyRoutes from './safety'; +import ecRoutes from './ec'; import financeRoutes from './finance'; import contentRoutes from './content'; import notificationRoutes from './notification'; @@ -21,4 +22,4 @@ const platformRoutes: AppRouteRecordRaw[] = [ { path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, roles: ['*'] } }, ] }, ]; -export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...commerceRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes]; +export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...safetyRoutes, ...ecRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes]; diff --git a/frontend/platform_admin/src/router/routes/modules/safety.ts b/frontend/platform_admin/src/router/routes/modules/safety.ts new file mode 100644 index 0000000..8af490d --- /dev/null +++ b/frontend/platform_admin/src/router/routes/modules/safety.ts @@ -0,0 +1,8 @@ +import { DEFAULT_LAYOUT } from '../base'; +import type { AppRouteRecordRaw } from '../types'; +const routes: AppRouteRecordRaw[] = [{ path: '/safety', name: 'safety', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.safety', requiresAuth: true, icon: 'icon-apps', order: 15 }, children: [ + { path: 'saf-rule', name: 'safety-saf-rule', component: () => import('@/views/safety/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_rule', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-event', name: 'safety-saf-event', component: () => import('@/views/safety/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_event', requiresAuth: true, roles: ['*'] } }, + { path: 'saf-inspection', name: 'safety-saf-inspection', component: () => import('@/views/safety/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_inspection', requiresAuth: true, roles: ['*'] } } +] }]; +export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/staff.ts b/frontend/platform_admin/src/router/routes/modules/staff.ts index 56e1ac3..c1e0f52 100644 --- a/frontend/platform_admin/src/router/routes/modules/staff.ts +++ b/frontend/platform_admin/src/router/routes/modules/staff.ts @@ -4,8 +4,8 @@ const routes: AppRouteRecordRaw[] = [{ path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-apps', order: 12 }, children: [ - { path: 'staff-account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, roles: ['*'] } }, - { path: 'staff-credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, roles: ['*'] } } + { path: 'account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, roles: ['*'] } }, + { path: 'credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, roles: ['*'] } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/router/routes/modules/user.ts b/frontend/platform_admin/src/router/routes/modules/user.ts index 1c6788b..15a6fbf 100644 --- a/frontend/platform_admin/src/router/routes/modules/user.ts +++ b/frontend/platform_admin/src/router/routes/modules/user.ts @@ -4,9 +4,9 @@ const routes: AppRouteRecordRaw[] = [{ path: '/user', name: 'user', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-apps', order: 13 }, children: [ - { path: 'user-account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, roles: ['*'] } }, - { path: 'user-address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, roles: ['*'] } }, - { path: 'user-service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, roles: ['*'] } } + { path: 'account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, roles: ['*'] } }, + { path: 'address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, roles: ['*'] } }, + { path: 'service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, roles: ['*'] } } ], }]; export default routes; diff --git a/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue deleted file mode 100644 index ba927e1..0000000 --- a/frontend/platform_admin/src/views/commerce/ec_product_attribute/ListPage.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue deleted file mode 100644 index 8b31cd4..0000000 --- a/frontend/platform_admin/src/views/commerce/ec_product_image/ListPage.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue b/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue deleted file mode 100644 index 0752bcd..0000000 --- a/frontend/platform_admin/src/views/commerce/ec_review/ListPage.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue b/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue deleted file mode 100644 index 9de65c9..0000000 --- a/frontend/platform_admin/src/views/device/saf_event_disposal/ListPage.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue b/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue deleted file mode 100644 index 3d198cb..0000000 --- a/frontend/platform_admin/src/views/device/saf_inspection/ListPage.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/platform_admin/src/views/device/saf_rule/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_cart/ListPage.vue similarity index 79% rename from frontend/platform_admin/src/views/device/saf_rule/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_cart/ListPage.vue index 52f5708..b151c31 100644 --- a/frontend/platform_admin/src/views/device/saf_rule/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_cart/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/device/saf_event/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue similarity index 79% rename from frontend/platform_admin/src/views/device/saf_event/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_category/ListPage.vue index d09abe1..331cbd3 100644 --- a/frontend/platform_admin/src/views/device/saf_event/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_order/ListPage.vue similarity index 79% rename from frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_order/ListPage.vue index fb82532..2224775 100644 --- a/frontend/platform_admin/src/views/commerce/ec_cart/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_order/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_order_item/ListPage.vue similarity index 79% rename from frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_order_item/ListPage.vue index 315a0b7..0b17456 100644 --- a/frontend/platform_admin/src/views/commerce/ec_order/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_order_item/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/ec/ec_product/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_product/ListPage.vue new file mode 100644 index 0000000..6f8009d --- /dev/null +++ b/frontend/platform_admin/src/views/ec/ec_product/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/ec/ec_product_attribute/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_product_attribute/ListPage.vue new file mode 100644 index 0000000..dcf8426 --- /dev/null +++ b/frontend/platform_admin/src/views/ec/ec_product_attribute/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_product_image/ListPage.vue similarity index 78% rename from frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_product_image/ListPage.vue index 224e6dc..925a8af 100644 --- a/frontend/platform_admin/src/views/commerce/ec_product/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_product_image/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/ec/ec_review/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_review/ListPage.vue new file mode 100644 index 0000000..8e4e311 --- /dev/null +++ b/frontend/platform_admin/src/views/ec/ec_review/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/safety/saf_event/ListPage.vue b/frontend/platform_admin/src/views/safety/saf_event/ListPage.vue new file mode 100644 index 0000000..2027f00 --- /dev/null +++ b/frontend/platform_admin/src/views/safety/saf_event/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue b/frontend/platform_admin/src/views/safety/saf_inspection/ListPage.vue similarity index 77% rename from frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue rename to frontend/platform_admin/src/views/safety/saf_inspection/ListPage.vue index 1b06ee1..1dcf8ed 100644 --- a/frontend/platform_admin/src/views/commerce/ec_category/ListPage.vue +++ b/frontend/platform_admin/src/views/safety/saf_inspection/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/safety/saf_rule/ListPage.vue b/frontend/platform_admin/src/views/safety/saf_rule/ListPage.vue new file mode 100644 index 0000000..150d29f --- /dev/null +++ b/frontend/platform_admin/src/views/safety/saf_rule/ListPage.vue @@ -0,0 +1,6 @@ + + diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 9cf6ddd..1d3d2f1 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -1,41 +1,25 @@ - + diff --git a/frontend/platform_admin/src/views/staff/list/ListPage.vue b/frontend/platform_admin/src/views/staff/list/ListPage.vue index 625e807..a8f3423 100644 --- a/frontend/platform_admin/src/views/staff/list/ListPage.vue +++ b/frontend/platform_admin/src/views/staff/list/ListPage.vue @@ -1 +1,6 @@ - + + diff --git a/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue b/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue index 6db1c6b..a8f3423 100644 --- a/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue +++ b/frontend/platform_admin/src/views/staff/staff_account/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue b/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue index ac1d4ad..233de6c 100644 --- a/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue +++ b/frontend/platform_admin/src/views/staff/staff_credential/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/user/list/ListPage.vue b/frontend/platform_admin/src/views/user/list/ListPage.vue index 06419ac..ab4ba3d 100644 --- a/frontend/platform_admin/src/views/user/list/ListPage.vue +++ b/frontend/platform_admin/src/views/user/list/ListPage.vue @@ -1 +1,6 @@ - + + diff --git a/frontend/platform_admin/src/views/user/user_account/ListPage.vue b/frontend/platform_admin/src/views/user/user_account/ListPage.vue index 70dab72..ab4ba3d 100644 --- a/frontend/platform_admin/src/views/user/user_account/ListPage.vue +++ b/frontend/platform_admin/src/views/user/user_account/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/user/user_address/ListPage.vue b/frontend/platform_admin/src/views/user/user_address/ListPage.vue index a17548a..1891985 100644 --- a/frontend/platform_admin/src/views/user/user_address/ListPage.vue +++ b/frontend/platform_admin/src/views/user/user_address/ListPage.vue @@ -2,5 +2,5 @@ diff --git a/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue b/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue index 461c15a..b4adbec 100644 --- a/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue +++ b/frontend/platform_admin/src/views/user/user_service_relation/ListPage.vue @@ -2,5 +2,5 @@ From b67c7b9ca92a58436ac812570e2c97a991813910 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 09:56:33 +0800 Subject: [PATCH 07/17] fix: enforce platform resource allowlists --- .../task-6-report.md | 8 + .../platform_admin/scripts/audit-check.mjs | 1259 ++++++++++++++++- frontend/platform_admin/src/api/resources.ts | 109 +- .../src/router/routes/modules/ec.ts | 2 +- .../{ListPage.vue => TreePage.vue} | 4 +- .../src/views/shared/CrudListPage.vue | 8 +- .../src/views/shared/TreePage.vue | 17 +- 7 files changed, 1302 insertions(+), 105 deletions(-) rename frontend/platform_admin/src/views/ec/ec_category/{ListPage.vue => TreePage.vue} (51%) diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md index ffbd95d..f696f6e 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -28,3 +28,11 @@ The tree page consumes `parent_identity`; the corresponding menu API must expose - Corrected the safety, e-commerce, staff, and user API paths to match the registered backend routes exactly. - Added audit assertions for exact path/mode/page-kind contracts, separate read-only UI surfaces, identifier leakage, and tree semantics. - Detail drawers now filter `id` and `*_id` fields before rendering. + +## Fix round 2 + +- The resource catalogue now contains exactly the 46 backend `ExpectedResources` names, including the append-only `saf_event_disposal` contract rather than a synthetic dashboard entry. +- Every definition has its own backend write allowlist and required `*_identity` fields; no resource inherits a generic `name/status` schema. +- Safety disposal is a detail action on `saf_event` (`/safety/saf_event/:identity/disposals`) and has no standalone menu or page. +- `ec_category` and `platform_menu` use tree pages. The shared tree preserves identity-first behavior and uses backend `parent_id` only for in-memory hierarchy adaptation when `parent_identity` is unavailable. +- The audit transpiles and evaluates `resources.ts`, then compares the resulting definitions, allowlists, required identities, modes, page types, tree semantics, and identifier-safety rules to the complete expected contract. diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 24c0b73..3d3f246 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,54 +1,1241 @@ import fs from 'node:fs'; import path from 'node:path'; - +import vm from 'node:vm'; +import ts from 'typescript'; const contracts = [ - ['dashboard', 'overview', '/dashboard/overview', 'readonly', 'dashboard'], - ['gas', 'gas_basic', '/gas/gas_basic', 'writable', 'list'], ['gas', 'gas_account', '/gas/gas_account', 'writable', 'list'], - ['delivery', 'delivery_basic', '/delivery/delivery_basic', 'writable', 'list'], ['delivery', 'delivery_account', '/delivery/delivery_account', 'writable', 'list'], ['delivery', 'delivery_task', '/delivery/delivery_task', 'writable', 'list'], ['delivery', 'delivery_track', '/delivery/delivery_track', 'writable', 'list'], ['delivery', 'delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list'], - ['staff', 'staff_account', '/staff/account', 'writable', 'list'], ['staff', 'staff_credential', '/staff/credential', 'writable', 'list'], - ['user', 'user_account', '/user/account', 'writable', 'list'], ['user', 'user_address', '/user/address', 'writable', 'list'], ['user', 'user_service_relation', '/user/service_relation', 'writable', 'list'], - ['device', 'dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list'], ['device', 'dev_device_binding', '/device/dev_device_binding', 'writable', 'list'], ['device', 'dev_telemetry', '/device/dev_telemetry', 'readonly', 'list'], - ['safety', 'saf_rule', '/safety/saf_rule', 'writable', 'list'], ['safety', 'saf_event', '/safety/saf_event', 'writable', 'list'], ['safety', 'saf_inspection', '/safety/saf_inspection', 'writable', 'list'], - ['ec', 'ec_category', '/ec/ec_category', 'writable', 'list'], ['ec', 'ec_product', '/ec/ec_product', 'writable', 'list'], ['ec', 'ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list'], ['ec', 'ec_product_image', '/ec/ec_product_image', 'writable', 'list'], ['ec', 'ec_cart', '/ec/ec_cart', 'writable', 'list'], ['ec', 'ec_order', '/ec/ec_order', 'writable', 'list'], ['ec', 'ec_order_item', '/ec/ec_order_item', 'writable', 'list'], ['ec', 'ec_review', '/ec/ec_review', 'writable', 'list'], - ['finance', 'fin_payment', '/finance/fin_payment', 'writable', 'list'], ['finance', 'fin_settlement', '/finance/fin_settlement', 'writable', 'list'], ['finance', 'fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list'], - ['content', 'cnt_content', '/content/cnt_content', 'writable', 'list'], ['notification', 'ntf_template', '/notification/ntf_template', 'writable', 'list'], ['customer_service', 'cs_ticket', '/customer_service/cs_ticket', 'writable', 'list'], - ['platform', 'platfrom_account', '/platform/platfrom_account', 'writable', 'list'], ['platform', 'platform_role', '/platform/platform_role', 'writable', 'list'], ['platform', 'platform_menu', '/platform/platform_menu', 'writable', 'tree'], - ['wallet', 'wallet', '/wallet/wallet', 'readonly', 'list'], ['wallet', 'wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list'], ['wallet', 'wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list'], ['wallet', 'wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list'], - ['report', 'report', '/report/report', 'readonly', 'list'], ['report', 'report_item', '/report/report_item', 'readonly', 'list'], ['report', 'report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list'], - ['audit', 'aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list'], ['audit', 'aud_export_log', '/audit/aud_export_log', 'readonly', 'list'], ['audit', 'aud_approval', '/audit/aud_approval', 'readonly', 'list'], + { + "domain": "gas", + "name": "gas_basic", + "resource": "/gas/gas_basic", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "credit_code", + "required": false + }, + { + "key": "principal", + "required": false + }, + { + "key": "address", + "required": false + }, + { + "key": "longitude", + "required": false + }, + { + "key": "latitude", + "required": false + } + ] + }, + { + "domain": "gas", + "name": "gas_account", + "resource": "/gas/gas_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "gas_basic_identity", + "required": true + } + ] + }, + { + "domain": "delivery", + "name": "delivery_basic", + "resource": "/delivery/delivery_basic", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "principal", + "required": false + }, + { + "key": "address", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_account", + "resource": "/delivery/delivery_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": true + } + ] + }, + { + "domain": "delivery", + "name": "delivery_task", + "resource": "/delivery/delivery_task", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "delivery_basic_identity", + "required": true + }, + { + "key": "staff_account_identity", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_track", + "resource": "/delivery/delivery_track", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_task_identity", + "required": true + }, + { + "key": "started_at", + "required": false + }, + { + "key": "completed_at", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_track_point", + "resource": "/delivery/delivery_track_point", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_track_identity", + "required": true + }, + { + "key": "point_type", + "required": true + }, + { + "key": "occurred_at", + "required": true + }, + { + "key": "longitude", + "required": true + }, + { + "key": "latitude", + "required": true + } + ] + }, + { + "domain": "staff", + "name": "staff_account", + "resource": "/staff/account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "phone", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "work_status", + "required": false + } + ] + }, + { + "domain": "staff", + "name": "staff_credential", + "resource": "/staff/credential", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "staff_account_identity", + "required": true + }, + { + "key": "credential_type", + "required": true + }, + { + "key": "credential_no", + "required": false + }, + { + "key": "expired_at", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_account", + "resource": "/user/account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "phone", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "real_name", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_address", + "resource": "/user/address", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "address", + "required": true + }, + { + "key": "longitude", + "required": false + }, + { + "key": "latitude", + "required": false + }, + { + "key": "is_default", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_service_relation", + "resource": "/user/service_relation", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "staff_account_identity", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_smart_cylinder_valve", + "resource": "/device/dev_smart_cylinder_valve", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "device_no", + "required": true + }, + { + "key": "model", + "required": false + }, + { + "key": "online_status", + "required": false + }, + { + "key": "owner_identity", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_device_binding", + "resource": "/device/dev_device_binding", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "smart_cylinder_valve_identity", + "required": true + }, + { + "key": "user_account_identity", + "required": true + }, + { + "key": "effective_at", + "required": false + }, + { + "key": "expired_at", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_telemetry", + "resource": "/device/dev_telemetry", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "smart_cylinder_valve_identity", + "required": false + }, + { + "key": "recorded_at", + "required": false + }, + { + "key": "payload", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_rule", + "resource": "/safety/saf_rule", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "rule_code", + "required": true + }, + { + "key": "version_no", + "required": false + }, + { + "key": "threshold", + "required": false + }, + { + "key": "action", + "required": false + }, + { + "key": "gray_scope", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_event", + "resource": "/safety/saf_event", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "event_code", + "required": true + }, + { + "key": "level", + "required": true + }, + { + "key": "title", + "required": true + }, + { + "key": "smart_cylinder_valve_identity", + "required": true + }, + { + "key": "sla_at", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_inspection", + "resource": "/safety/saf_inspection", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "staff_account_identity", + "required": true + }, + { + "key": "result", + "required": true + }, + { + "key": "evidence_uri", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_event_disposal", + "resource": "/safety/saf_event/:identity/disposals", + "mode": "append_only", + "pageKind": "list", + "fields": [ + { + "key": "action", + "required": true + }, + { + "key": "reason", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_category", + "resource": "/ec/ec_category", + "mode": "writable", + "pageKind": "tree", + "fields": [ + { + "key": "parent_identity", + "required": false + }, + { + "key": "name", + "required": true + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product", + "resource": "/ec/ec_product", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_category_identity", + "required": true + }, + { + "key": "product_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "price_amount", + "required": true + }, + { + "key": "stock_quantity", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product_attribute", + "resource": "/ec/ec_product_attribute", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "value", + "required": true + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product_image", + "resource": "/ec/ec_product_image", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "image_uri", + "required": true + }, + { + "key": "sort_no", + "required": false + }, + { + "key": "is_cover", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_cart", + "resource": "/ec/ec_cart", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "quantity", + "required": true + }, + { + "key": "selected", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_order", + "resource": "/ec/ec_order", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "order_no", + "required": true + }, + { + "key": "total_amount", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_order_item", + "resource": "/ec/ec_order_item", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "product_snapshot", + "required": true + }, + { + "key": "quantity", + "required": true + }, + { + "key": "sale_amount", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_review", + "resource": "/ec/ec_review", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "user_account_identity", + "required": true + }, + { + "key": "score", + "required": true + }, + { + "key": "content", + "required": true + } + ] + }, + { + "domain": "finance", + "name": "fin_payment", + "resource": "/finance/fin_payment", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "channel", + "required": true + }, + { + "key": "amount", + "required": true + }, + { + "key": "paid_at", + "required": false + } + ] + }, + { + "domain": "finance", + "name": "fin_settlement", + "resource": "/finance/fin_settlement", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "settlement_no", + "required": true + }, + { + "key": "subject_type", + "required": true + }, + { + "key": "subject_identity", + "required": true + }, + { + "key": "period_start", + "required": true + }, + { + "key": "period_end", + "required": true + } + ] + }, + { + "domain": "finance", + "name": "fin_reconciliation", + "resource": "/finance/fin_reconciliation", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "channel", + "required": true + }, + { + "key": "bill_date", + "required": true + }, + { + "key": "difference_amount", + "required": true + } + ] + }, + { + "domain": "content", + "name": "cnt_content", + "resource": "/content/cnt_content", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "content_type", + "required": true + }, + { + "key": "title", + "required": true + }, + { + "key": "body", + "required": true + }, + { + "key": "version_no", + "required": false + }, + { + "key": "publish_status", + "required": false + } + ] + }, + { + "domain": "notification", + "name": "ntf_template", + "resource": "/notification/ntf_template", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "template_code", + "required": true + }, + { + "key": "channel", + "required": true + }, + { + "key": "content", + "required": true + } + ] + }, + { + "domain": "customer_service", + "name": "cs_ticket", + "resource": "/customer_service/cs_ticket", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "ticket_no", + "required": true + }, + { + "key": "category", + "required": true + }, + { + "key": "priority", + "required": true + } + ] + }, + { + "domain": "platform", + "name": "platfrom_account", + "resource": "/platform/platfrom_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "platform_role_code", + "required": false + }, + { + "key": "phone", + "required": false + } + ] + }, + { + "domain": "platform", + "name": "platform_role", + "resource": "/platform/platform_role", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "role_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "data_scope", + "required": true + } + ] + }, + { + "domain": "platform", + "name": "platform_menu", + "resource": "/platform/platform_menu", + "mode": "writable", + "pageKind": "tree", + "fields": [ + { + "key": "parent_identity", + "required": false + }, + { + "key": "menu_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "icon", + "required": false + }, + { + "key": "path", + "required": false + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet", + "resource": "/wallet/wallet", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "owner_identity", + "required": false + }, + { + "key": "balance_amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_ledger", + "resource": "/wallet/wallet_ledger", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "change_amount", + "required": false + }, + { + "key": "balance_after", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_recharge", + "resource": "/wallet/wallet_recharge", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_withdrawal", + "resource": "/wallet/wallet_withdrawal", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report", + "resource": "/report/report", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_code", + "required": false + }, + { + "key": "name", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report_item", + "resource": "/report/report_item", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_identity", + "required": false + }, + { + "key": "metric_code", + "required": false + }, + { + "key": "value", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report_metric_snapshot", + "resource": "/report/report_metric_snapshot", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_identity", + "required": false + }, + { + "key": "metric_code", + "required": false + }, + { + "key": "value", + "required": false + }, + { + "key": "captured_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_operation_log", + "resource": "/audit/aud_operation_log", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "action", + "required": false + }, + { + "key": "object_identity", + "required": false + }, + { + "key": "created_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_export_log", + "resource": "/audit/aud_export_log", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "resource_type", + "required": false + }, + { + "key": "created_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_approval", + "resource": "/audit/aud_approval", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "status", + "required": false + }, + { + "key": "handled_at", + "required": false + } + ] + } ]; - -const resourceSource = fs.readFileSync('src/api/resources.ts', 'utf8'); -const resourceEntry = (name, resource, mode, pageKind) => new RegExp(`name: '${name}', resource: '${resource}', title: '[^']+', mode: '${mode}', pageKind: '${pageKind}'`); +const source = fs.readFileSync('src/api/resources.ts', 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; +const resourceModule = { exports: {} }; +vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); +const resources = resourceModule.exports.resources; const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : []; - +const sameFields = (actual, expected) => Array.isArray(actual) && actual.length === expected.length && actual.every((field, index) => field.key === expected[index].key && Boolean(field.required) === Boolean(expected[index].required)); function checkContracts() { const failures = []; - if (contracts.length !== 46) failures.push(`catalogue: expected 46 resources, got ${contracts.length}`); - for (const [domain, name, resource, mode, pageKind] of contracts) { - const label = `${domain}/${name}`; - if (!resourceEntry(name, resource, mode, pageKind).test(resourceSource)) failures.push(`${label}: missing or incorrect api contract`); - const route = path.join('src/router/routes/modules', domain === 'dashboard' ? 'platform.ts' : `${domain}.ts`); - if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(name)) failures.push(`${label}: missing route`); - const page = pageKind === 'dashboard' ? path.join('src/views/dashboard/DashboardPage.vue') : path.join('src/views', domain, name, pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); + if (!Array.isArray(resources) || resources.length !== contracts.length) failures.push(`catalogue: expected ${contracts.length} resources`); + for (const contract of contracts) { + const definition = resources.find((resource) => resource.name === contract.name); + const label = `${contract.domain}/${contract.name}`; + if (!definition || definition.resource !== contract.resource || definition.mode !== contract.mode || definition.pageKind !== contract.pageKind || !sameFields(definition.fields, contract.fields)) failures.push(`${label}: mismatched contract or allowlist`); + const requiredIdentity = contract.fields.filter((field) => field.required && field.key.endsWith('_identity')).map((field) => field.key); + if (definition && JSON.stringify(definition.requiredIdentities ?? []) !== JSON.stringify(requiredIdentity)) failures.push(`${label}: required identity fields do not match allowlist`); + if (contract.name === 'saf_event_disposal') continue; + const route = path.join('src/router/routes/modules', contract.domain + '.ts'); + if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(contract.name)) failures.push(`${label}: missing route`); + const page = path.join('src/views', contract.domain, contract.name, contract.pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); if (!fs.existsSync(page)) failures.push(`${label}: missing view`); - if (mode === 'readonly' && pageKind !== 'dashboard' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); + if (contract.mode === 'readonly' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); } + const names = resources.map((resource) => resource.name).sort(); + const expectedNames = contracts.map((contract) => contract.name).sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames)) failures.push('catalogue: resource names do not equal ExpectedResources'); + if (resources.some((resource) => resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')))) failures.push('catalogue: internal identifier leaked through fields'); const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); - if (/(CrudListPage|resourceApi\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); + if (/(CrudListPage|resourceApi\\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); - if (!treePage.includes(' resource.name === 'saf_event'); + if (!safetyEvent?.detailActions?.some((item) => item.name === 'saf_event_disposal' && item.resource === '/safety/saf_event/:identity/disposals')) failures.push('safety event: missing disposal detail action'); + if (files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal')) || fs.existsSync('src/views/safety/saf_event_disposal')) failures.push('saf_event_disposal: must not have independent menu or page'); const crudSource = fs.readFileSync('src/views/shared/CrudListPage.vue', 'utf8'); if (!crudSource.includes("key !== 'id'") || !crudSource.includes("key.endsWith('_id')")) failures.push('detail view: does not remove internal identifiers'); - const identityLeaks = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); - if (identityLeaks.length) failures.push(`identity leak: ${identityLeaks.join(', ')}`); - if (resourceSource.includes('saf_event_disposal') || files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal'))) failures.push('saf_event_disposal: must not be displayed as an independent resource'); - const obsoletePaths = /\/(?:commerce|staff\/staff_|user\/user_)/; - const stalePathFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && obsoletePaths.test(fs.readFileSync(file, 'utf8'))); - if (stalePathFiles.length) failures.push(`obsolete resource path: ${stalePathFiles.join(', ')}`); + const leakedUiFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue') && !file.endsWith('TreePage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); + if (leakedUiFiles.length) failures.push(`identity leak: ${leakedUiFiles.join(', ')}`); + if (/data-index="(?:id|[^" ]+_id)"|\{\{\s*[^}]*\b(?:id|[A-Za-z][A-Za-z0-9]*_id)\b/.test(treePage)) failures.push('tree page: displays internal identifier'); return failures; } - const failures = checkContracts(); for (const failure of failures) console.log(failure); if (failures.length) process.exitCode = 1; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 82b0da1..1c09dd4 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -1,57 +1,66 @@ export type ResourceMode = 'writable' | 'readonly' | 'append_only'; -export type ResourcePageKind = 'list' | 'tree' | 'dashboard'; +export type ResourcePageKind = 'list' | 'tree'; export type ResourceField = { key: string; label: string; required?: boolean }; -export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[] }; -const fields: ResourceField[] = [{ key: 'name', label: '名称' }, { key: 'status', label: '状态' }]; -/** Exact front-end representation of the platform HTTP resource contract. */ +export type DetailAction = { name: string; resource: string; fields: ResourceField[] }; +export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; requiredIdentities: string[]; detailActions?: DetailAction[] }; + +const field = (value: string): ResourceField => ({ key: value.replace(/!$/, ''), label: value.replace(/!$/, ''), required: value.endsWith('!') || undefined }); +const define = (name: string, resource: string, mode: ResourceMode, pageKind: ResourcePageKind, keys: string[], detailActions?: DetailAction[]): ResourceUiDefinition => { + const fields = keys.map(field); + return { key: name.replace(/_/g, '-'), name, resource, title: name, mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) }; +}; +const action = (name: string, resource: string, keys: string[]): DetailAction => ({ name, resource, fields: keys.map(field) }); + +/** Exact UI contract for every backend ExpectedResources entry. */ export const resources: ResourceUiDefinition[] = [ - { key: 'overview', name: 'overview', resource: '/dashboard/overview', title: 'overview', mode: 'readonly', pageKind: 'dashboard', fields }, - { key: 'gas-basic', name: 'gas_basic', resource: '/gas/gas_basic', title: 'gas_basic', mode: 'writable', pageKind: 'list', fields }, - { key: 'gas-account', name: 'gas_account', resource: '/gas/gas_account', title: 'gas_account', mode: 'writable', pageKind: 'list', fields }, - { key: 'delivery-basic', name: 'delivery_basic', resource: '/delivery/delivery_basic', title: 'delivery_basic', mode: 'writable', pageKind: 'list', fields }, - { key: 'delivery-account', name: 'delivery_account', resource: '/delivery/delivery_account', title: 'delivery_account', mode: 'writable', pageKind: 'list', fields }, - { key: 'delivery-task', name: 'delivery_task', resource: '/delivery/delivery_task', title: 'delivery_task', mode: 'writable', pageKind: 'list', fields }, - { key: 'delivery-track', name: 'delivery_track', resource: '/delivery/delivery_track', title: 'delivery_track', mode: 'writable', pageKind: 'list', fields }, - { key: 'delivery-track-point', name: 'delivery_track_point', resource: '/delivery/delivery_track_point', title: 'delivery_track_point', mode: 'writable', pageKind: 'list', fields }, - { key: 'staff-account', name: 'staff_account', resource: '/staff/account', title: 'staff_account', mode: 'writable', pageKind: 'list', fields }, - { key: 'staff-credential', name: 'staff_credential', resource: '/staff/credential', title: 'staff_credential', mode: 'writable', pageKind: 'list', fields }, - { key: 'user-account', name: 'user_account', resource: '/user/account', title: 'user_account', mode: 'writable', pageKind: 'list', fields }, - { key: 'user-address', name: 'user_address', resource: '/user/address', title: 'user_address', mode: 'writable', pageKind: 'list', fields }, - { key: 'user-service-relation', name: 'user_service_relation', resource: '/user/service_relation', title: 'user_service_relation', mode: 'writable', pageKind: 'list', fields }, - { key: 'dev-smart-cylinder-valve', name: 'dev_smart_cylinder_valve', resource: '/device/dev_smart_cylinder_valve', title: 'dev_smart_cylinder_valve', mode: 'writable', pageKind: 'list', fields }, - { key: 'dev-device-binding', name: 'dev_device_binding', resource: '/device/dev_device_binding', title: 'dev_device_binding', mode: 'writable', pageKind: 'list', fields }, - { key: 'dev-telemetry', name: 'dev_telemetry', resource: '/device/dev_telemetry', title: 'dev_telemetry', mode: 'readonly', pageKind: 'list', fields }, - { key: 'saf-rule', name: 'saf_rule', resource: '/safety/saf_rule', title: 'saf_rule', mode: 'writable', pageKind: 'list', fields }, - { key: 'saf-event', name: 'saf_event', resource: '/safety/saf_event', title: 'saf_event', mode: 'writable', pageKind: 'list', fields }, - { key: 'saf-inspection', name: 'saf_inspection', resource: '/safety/saf_inspection', title: 'saf_inspection', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-category', name: 'ec_category', resource: '/ec/ec_category', title: 'ec_category', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-product', name: 'ec_product', resource: '/ec/ec_product', title: 'ec_product', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-product-attribute', name: 'ec_product_attribute', resource: '/ec/ec_product_attribute', title: 'ec_product_attribute', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-product-image', name: 'ec_product_image', resource: '/ec/ec_product_image', title: 'ec_product_image', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-cart', name: 'ec_cart', resource: '/ec/ec_cart', title: 'ec_cart', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-order', name: 'ec_order', resource: '/ec/ec_order', title: 'ec_order', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-order-item', name: 'ec_order_item', resource: '/ec/ec_order_item', title: 'ec_order_item', mode: 'writable', pageKind: 'list', fields }, - { key: 'ec-review', name: 'ec_review', resource: '/ec/ec_review', title: 'ec_review', mode: 'writable', pageKind: 'list', fields }, - { key: 'fin-payment', name: 'fin_payment', resource: '/finance/fin_payment', title: 'fin_payment', mode: 'writable', pageKind: 'list', fields }, - { key: 'fin-settlement', name: 'fin_settlement', resource: '/finance/fin_settlement', title: 'fin_settlement', mode: 'writable', pageKind: 'list', fields }, - { key: 'fin-reconciliation', name: 'fin_reconciliation', resource: '/finance/fin_reconciliation', title: 'fin_reconciliation', mode: 'writable', pageKind: 'list', fields }, - { key: 'cnt-content', name: 'cnt_content', resource: '/content/cnt_content', title: 'cnt_content', mode: 'writable', pageKind: 'list', fields }, - { key: 'ntf-template', name: 'ntf_template', resource: '/notification/ntf_template', title: 'ntf_template', mode: 'writable', pageKind: 'list', fields }, - { key: 'cs-ticket', name: 'cs_ticket', resource: '/customer_service/cs_ticket', title: 'cs_ticket', mode: 'writable', pageKind: 'list', fields }, - { key: 'platfrom-account', name: 'platfrom_account', resource: '/platform/platfrom_account', title: 'platfrom_account', mode: 'writable', pageKind: 'list', fields }, - { key: 'platform-role', name: 'platform_role', resource: '/platform/platform_role', title: 'platform_role', mode: 'writable', pageKind: 'list', fields }, - { key: 'platform-menu', name: 'platform_menu', resource: '/platform/platform_menu', title: 'platform_menu', mode: 'writable', pageKind: 'tree', fields }, - { key: 'wallet', name: 'wallet', resource: '/wallet/wallet', title: 'wallet', mode: 'readonly', pageKind: 'list', fields }, - { key: 'wallet-ledger', name: 'wallet_ledger', resource: '/wallet/wallet_ledger', title: 'wallet_ledger', mode: 'readonly', pageKind: 'list', fields }, - { key: 'wallet-recharge', name: 'wallet_recharge', resource: '/wallet/wallet_recharge', title: 'wallet_recharge', mode: 'readonly', pageKind: 'list', fields }, - { key: 'wallet-withdrawal', name: 'wallet_withdrawal', resource: '/wallet/wallet_withdrawal', title: 'wallet_withdrawal', mode: 'readonly', pageKind: 'list', fields }, - { key: 'report', name: 'report', resource: '/report/report', title: 'report', mode: 'readonly', pageKind: 'list', fields }, - { key: 'report-item', name: 'report_item', resource: '/report/report_item', title: 'report_item', mode: 'readonly', pageKind: 'list', fields }, - { key: 'report-metric-snapshot', name: 'report_metric_snapshot', resource: '/report/report_metric_snapshot', title: 'report_metric_snapshot', mode: 'readonly', pageKind: 'list', fields }, - { key: 'aud-operation-log', name: 'aud_operation_log', resource: '/audit/aud_operation_log', title: 'aud_operation_log', mode: 'readonly', pageKind: 'list', fields }, - { key: 'aud-export-log', name: 'aud_export_log', resource: '/audit/aud_export_log', title: 'aud_export_log', mode: 'readonly', pageKind: 'list', fields }, - { key: 'aud-approval', name: 'aud_approval', resource: '/audit/aud_approval', title: 'aud_approval', mode: 'readonly', pageKind: 'list', fields }, + define('gas_basic', '/gas/gas_basic', 'writable', 'list', ['code!', 'name!', 'credit_code', 'principal', 'address', 'longitude', 'latitude']), + define('gas_account', '/gas/gas_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'gas_basic_identity!']), + define('delivery_basic', '/delivery/delivery_basic', 'writable', 'list', ['delivery_code!', 'name!', 'gas_basic_identity', 'principal', 'address']), + define('delivery_account', '/delivery/delivery_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'delivery_basic_identity!']), + define('delivery_task', '/delivery/delivery_task', 'writable', 'list', ['ec_order_identity!', 'delivery_basic_identity!', 'staff_account_identity']), + define('delivery_track', '/delivery/delivery_track', 'writable', 'list', ['delivery_task_identity!', 'started_at', 'completed_at']), + define('delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list', ['delivery_track_identity!', 'point_type!', 'occurred_at!', 'longitude!', 'latitude!']), + define('staff_account', '/staff/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'role_code', 'gas_basic_identity', 'delivery_basic_identity', 'work_status']), + define('staff_credential', '/staff/credential', 'writable', 'list', ['staff_account_identity!', 'credential_type!', 'credential_no', 'expired_at']), + define('user_account', '/user/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'real_name']), + define('user_address', '/user/address', 'writable', 'list', ['user_account_identity!', 'address!', 'longitude', 'latitude', 'is_default']), + define('user_service_relation', '/user/service_relation', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'staff_account_identity']), + define('dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list', ['device_no!', 'model', 'online_status', 'owner_identity']), + define('dev_device_binding', '/device/dev_device_binding', 'writable', 'list', ['smart_cylinder_valve_identity!', 'user_account_identity!', 'effective_at', 'expired_at']), + define('dev_telemetry', '/device/dev_telemetry', 'readonly', 'list', ['smart_cylinder_valve_identity', 'recorded_at', 'payload']), + define('saf_rule', '/safety/saf_rule', 'writable', 'list', ['rule_code!', 'version_no', 'threshold', 'action', 'gray_scope']), + define('saf_event', '/safety/saf_event', 'writable', 'list', ['event_code!', 'level!', 'title!', 'smart_cylinder_valve_identity!', 'sla_at'], [action('saf_event_disposal', '/safety/saf_event/:identity/disposals', ['action!', 'reason!'])]), + define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', ['user_account_identity!', 'staff_account_identity!', 'result!', 'evidence_uri']), + define('saf_event_disposal', '/safety/saf_event/:identity/disposals', 'append_only', 'list', ['action!', 'reason!']), + define('ec_category', '/ec/ec_category', 'writable', 'tree', ['parent_identity', 'name!', 'sort_no']), + define('ec_product', '/ec/ec_product', 'writable', 'list', ['ec_category_identity!', 'product_code!', 'name!', 'price_amount!', 'stock_quantity']), + define('ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list', ['ec_product_identity!', 'name!', 'value!', 'sort_no']), + define('ec_product_image', '/ec/ec_product_image', 'writable', 'list', ['ec_product_identity!', 'image_uri!', 'sort_no', 'is_cover']), + define('ec_cart', '/ec/ec_cart', 'writable', 'list', ['user_account_identity!', 'ec_product_identity!', 'quantity!', 'selected']), + define('ec_order', '/ec/ec_order', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'order_no!', 'total_amount!']), + define('ec_order_item', '/ec/ec_order_item', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'product_snapshot!', 'quantity!', 'sale_amount!']), + define('ec_review', '/ec/ec_review', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'user_account_identity!', 'score!', 'content!']), + define('fin_payment', '/finance/fin_payment', 'writable', 'list', ['ec_order_identity!', 'channel!', 'amount!', 'paid_at']), + define('fin_settlement', '/finance/fin_settlement', 'writable', 'list', ['settlement_no!', 'subject_type!', 'subject_identity!', 'period_start!', 'period_end!']), + define('fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list', ['channel!', 'bill_date!', 'difference_amount!']), + define('cnt_content', '/content/cnt_content', 'writable', 'list', ['content_type!', 'title!', 'body!', 'version_no', 'publish_status']), + define('ntf_template', '/notification/ntf_template', 'writable', 'list', ['template_code!', 'channel!', 'content!']), + define('cs_ticket', '/customer_service/cs_ticket', 'writable', 'list', ['user_account_identity!', 'ticket_no!', 'category!', 'priority!']), + define('platfrom_account', '/platform/platfrom_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'avatar', 'platform_role_code', 'phone']), + define('platform_role', '/platform/platform_role', 'writable', 'list', ['role_code!', 'name!', 'data_scope!']), + define('platform_menu', '/platform/platform_menu', 'writable', 'tree', ['parent_identity', 'menu_code!', 'name!', 'icon', 'path', 'sort_no']), + define('wallet', '/wallet/wallet', 'readonly', 'list', ['owner_identity', 'balance_amount', 'status']), + define('wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list', ['wallet_identity', 'change_amount', 'balance_after']), + define('wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list', ['wallet_identity', 'amount', 'status']), + define('wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list', ['wallet_identity', 'amount', 'status']), + define('report', '/report/report', 'readonly', 'list', ['report_code', 'name', 'status']), + define('report_item', '/report/report_item', 'readonly', 'list', ['report_identity', 'metric_code', 'value']), + define('report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list', ['report_identity', 'metric_code', 'value', 'captured_at']), + define('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', ['operator_identity', 'action', 'object_identity', 'created_at']), + define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', ['operator_identity', 'resource_type', 'created_at']), + define('aud_approval', '/audit/aud_approval', 'readonly', 'list', ['operator_identity', 'status', 'handled_at']), ]; + export const resourceByName = Object.fromEntries(resources.map((definition) => [definition.resource, definition])) as Record; export function getResource(resourcePath: string): ResourceUiDefinition { const definition = resourceByName[resourcePath]; diff --git a/frontend/platform_admin/src/router/routes/modules/ec.ts b/frontend/platform_admin/src/router/routes/modules/ec.ts index 835ed68..917cc35 100644 --- a/frontend/platform_admin/src/router/routes/modules/ec.ts +++ b/frontend/platform_admin/src/router/routes/modules/ec.ts @@ -1,7 +1,7 @@ import { DEFAULT_LAYOUT } from '../base'; import type { AppRouteRecordRaw } from '../types'; const routes: AppRouteRecordRaw[] = [{ path: '/ec', name: 'ec', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.ec', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [ - { path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } }, + { path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/TreePage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } }, { path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, roles: ['*'] } }, { path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, roles: ['*'] } }, { path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, roles: ['*'] } }, diff --git a/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_category/TreePage.vue similarity index 51% rename from frontend/platform_admin/src/views/ec/ec_category/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_category/TreePage.vue index 331cbd3..e7de76b 100644 --- a/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_category/TreePage.vue @@ -1,6 +1,6 @@ - + diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 1d3d2f1..8b1d039 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -6,17 +6,19 @@ - {{ value ?? '-' }} + {{ value ?? '-' }}{{ action.name }} + From 1d956094774b643afb613cc49b62b492a1539ab9 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 10:11:07 +0800 Subject: [PATCH 08/17] fix: resolve platform relation identities --- .../task-6-report.md | 7 + .../api/internal/logic/platform/account.go | 52 ++++-- .../api/internal/logic/platform/delivery.go | 34 ++-- .../internal/logic/platform/resource_test.go | 34 ++++ backend/api/internal/logic/platform/role.go | 68 ++++++-- .../logic/platform/secondary_resource.go | 98 +++++++++--- backend/api/internal/logic/platform/staff.go | 56 ++++--- .../logic/platform/task4_resources.go | 151 +++++++++++++++++- backend/api/internal/routers/platform.go | 7 +- .../platform_admin/scripts/audit-check.mjs | 6 +- .../src/views/shared/TreePage.vue | 4 +- 11 files changed, 437 insertions(+), 80 deletions(-) diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md index f696f6e..6a7f44f 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -36,3 +36,10 @@ The tree page consumes `parent_identity`; the corresponding menu API must expose - Safety disposal is a detail action on `saf_event` (`/safety/saf_event/:identity/disposals`) and has no standalone menu or page. - `ec_category` and `platform_menu` use tree pages. The shared tree preserves identity-first behavior and uses backend `parent_id` only for in-memory hierarchy adaptation when `parent_identity` is unavailable. - The audit transpiles and evaluates `resources.ts`, then compares the resulting definitions, allowlists, required identities, modes, page types, tree semantics, and identifier-safety rules to the complete expected contract. + +## Fix round 3 + +- Added tested backend identity-to-key resolution for public account and relationship requests; clients send `*_identity`, while numeric keys remain internal persistence details. +- Platform-menu and e-commerce-category tree responses now expose `parent_identity` and omit `parent_id`; the front-end tree no longer falls back to numeric keys. +- Settlement input accepts `subject_identity` with supported gas, delivery, and staff subject types. +- The platform audit now cross-checks every front-end definition against the backend resource catalogue and registered route source. diff --git a/backend/api/internal/logic/platform/account.go b/backend/api/internal/logic/platform/account.go index f97aaff..92fd00d 100644 --- a/backend/api/internal/logic/platform/account.go +++ b/backend/api/internal/logic/platform/account.go @@ -10,19 +10,19 @@ import ( ) type accountRequest struct { - Username string `json:"username" binding:"required,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - DisplayName string `json:"display_name" binding:"max=64"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` + Username string `json:"username" binding:"required,max=64"` + Password string `json:"password" binding:"required,min=8,max=128"` + DisplayName string `json:"display_name" binding:"max=64"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` } type accountUpdateRequest struct { - DisplayName string `json:"display_name" binding:"max=64"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` + DisplayName string `json:"display_name" binding:"max=64"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` } func passwordHash(password string) (string, error) { @@ -35,7 +35,12 @@ func GetGasAccount(ctx *gin.Context) { getByIdentity[models.GasAccount](ctx) } func CreateGasAccount(ctx *gin.Context) { var request accountRequest - if err := ctx.ShouldBindJSON(&request); err != nil || request.GasBasicID == 0 { + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -44,7 +49,7 @@ func CreateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.GasAccount{Entity: newEntity("enabled"), GasBasicID: request.GasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.GasAccount{Entity: newEntity("enabled"), GasBasicID: gasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return @@ -58,7 +63,12 @@ func UpdateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": request.GasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": gasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) } func ListDeliveryAccount(ctx *gin.Context) { listPage[models.DeliveryAccount](ctx) } @@ -66,7 +76,12 @@ func GetDeliveryAccount(ctx *gin.Context) { getByIdentity[models.DeliveryAccoun func CreateDeliveryAccount(ctx *gin.Context) { var request accountRequest - if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryBasicID == 0 { + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -75,7 +90,7 @@ func CreateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.DeliveryAccount{Entity: newEntity("enabled"), DeliveryBasicID: request.DeliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.DeliveryAccount{Entity: newEntity("enabled"), DeliveryBasicID: deliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return @@ -89,5 +104,10 @@ func UpdateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": request.DeliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": deliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) } diff --git a/backend/api/internal/logic/platform/delivery.go b/backend/api/internal/logic/platform/delivery.go index 7409331..9547528 100644 --- a/backend/api/internal/logic/platform/delivery.go +++ b/backend/api/internal/logic/platform/delivery.go @@ -16,30 +16,46 @@ func GetDeliveryBasic(ctx *gin.Context) { getByIdentity[models.DeliveryBasic](ct // CreateDeliveryBasic 创建配送点档案。 func CreateDeliveryBasic(ctx *gin.Context) { - var request models.DeliveryBasic + var request struct { + DeliveryCode string `json:"delivery_code"` + GasBasicIdentity string `json:"gas_basic_identity"` + Name string `json:"name"` + Principal string `json:"principal"` + Address string `json:"address"` + } if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryCode == "" || request.Name == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - request.Entity = newEntity("draft") - if err := impl.DBService.Create(&request).Error; err != nil { + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + delivery := models.DeliveryBasic{Entity: newEntity("draft"), DeliveryCode: request.DeliveryCode, GasBasicID: gasBasicID, Name: request.Name, Principal: request.Principal, Address: request.Address} + if err := impl.DBService.Create(&delivery).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, request) + infra.Response.Success(ctx, resourceResponse(delivery)) } // UpdateDeliveryBasic 更新配送点基础资料。 func UpdateDeliveryBasic(ctx *gin.Context) { var request struct { - GasBasicID uint64 `json:"gas_basic_id"` - Name string `json:"name" binding:"required,max=128"` - Principal string `json:"principal" binding:"max=64"` - Address string `json:"address" binding:"max=255"` + GasBasicIdentity string `json:"gas_basic_identity"` + Name string `json:"name" binding:"required,max=128"` + Principal string `json:"principal" binding:"max=64"` + Address string `json:"address" binding:"max=255"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": request.GasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": gasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) } diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index bdd5596..ce897f0 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -214,6 +214,40 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid } } +func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). + WithArgs("gas-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(8))) + mock.ExpectBegin() + mock.ExpectQuery(`INSERT INTO "gas_account"`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectCommit() + + ctx, recorder := updateContext(http.MethodPost, "/gas/gas_account", "", []byte(`{"username":"operator","password":"password-123","gas_basic_identity":"gas-a"}`)) + CreateGasAccount(ctx) + + assertResponseCode(t, recorder, 0) + assertMockExpectations(t, mock) +} + +func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(uint64(1), "root-a", nil, nil, "enabled", 1, uint64(0), "root", "Root", "", "", 1). + AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2)) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) + ListPlatformMenu(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"parent_identity":"root-a"`) || strings.Contains(recorder.Body.String(), `"parent_id"`) { + t.Fatalf("menu response leaked parent_id or omitted identity: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + 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) diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/role.go index e164edd..1edbd62 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/role.go @@ -61,15 +61,53 @@ func UpdatePlatformRole(ctx *gin.Context) { } type platformMenuRequest struct { - ParentID uint64 `json:"parent_id"` - MenuCode string `json:"menu_code" binding:"required,max=64"` - Name string `json:"name" binding:"required,max=64"` - Icon string `json:"icon" binding:"max=64"` - Path string `json:"path" binding:"max=255"` - SortNo int `json:"sort_no"` + ParentIdentity string `json:"parent_identity"` + MenuCode string `json:"menu_code" binding:"required,max=64"` + Name string `json:"name" binding:"required,max=64"` + Icon string `json:"icon" binding:"max=64"` + Path string `json:"path" binding:"max=255"` + SortNo int `json:"sort_no"` } -func GetPlatformMenu(ctx *gin.Context) { getByIdentity[models.PlatformMenu](ctx) } +type platformMenuView struct { + Identity string `json:"identity"` + ParentIdentity string `json:"parent_identity,omitempty"` + MenuCode string `json:"menu_code"` + Name string `json:"name"` + Icon string `json:"icon"` + Path string `json:"path"` + SortNo int `json:"sort_no"` + Status string `json:"status"` +} + +func platformMenuViews(list []models.PlatformMenu) []platformMenuView { + identities := make(map[uint64]string, len(list)) + for _, item := range list { + identities[item.ID] = item.Identity + } + views := make([]platformMenuView, 0, len(list)) + for _, item := range list { + views = append(views, platformMenuView{Identity: item.Identity, ParentIdentity: identities[item.ParentID], MenuCode: item.MenuCode, Name: item.Name, Icon: item.Icon, Path: item.Path, SortNo: item.SortNo, Status: item.Status}) + } + return views +} + +func GetPlatformMenu(ctx *gin.Context) { + var menu models.PlatformMenu + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&menu).Error; err != nil { + respondRecordError(ctx, err) + return + } + list := []models.PlatformMenu{menu} + if menu.ParentID != 0 { + var parent models.PlatformMenu + if err := impl.DBService.Select("id", "identity").First(&parent, menu.ParentID).Error; err == nil { + list = append(list, parent) + } + } + views := platformMenuViews(list) + infra.Response.Success(ctx, views[0]) +} func CreatePlatformMenu(ctx *gin.Context) { var request platformMenuRequest @@ -77,7 +115,12 @@ func CreatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - menu := models.PlatformMenu{Entity: newEntity("enabled"), ParentID: request.ParentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} + parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + menu := models.PlatformMenu{Entity: newEntity("enabled"), ParentID: parentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} if err := impl.DBService.Create(&menu).Error; err != nil { infra.Response.Error(ctx, err) return @@ -91,7 +134,12 @@ func UpdatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": request.ParentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) + parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": parentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) } type platformRoleMenusRequest struct { @@ -189,7 +237,7 @@ func ListPlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": len(list), "list": list}) + infra.Response.Success(ctx, gin.H{"total": len(list), "list": platformMenuViews(list)}) } // ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。 diff --git a/backend/api/internal/logic/platform/secondary_resource.go b/backend/api/internal/logic/platform/secondary_resource.go index 550c819..d288a5c 100644 --- a/backend/api/internal/logic/platform/secondary_resource.go +++ b/backend/api/internal/logic/platform/secondary_resource.go @@ -11,10 +11,10 @@ import ( ) type staffCredentialRequest struct { - StaffAccountID uint64 `json:"staff_account_id" binding:"required"` - CredentialType string `json:"credential_type" binding:"required,max=64"` - CredentialNo string `json:"credential_no" binding:"max=128"` - ExpiredAt *time.Time `json:"expired_at"` + StaffAccountIdentity string `json:"staff_account_identity" binding:"required"` + CredentialType string `json:"credential_type" binding:"required,max=64"` + CredentialNo string `json:"credential_no" binding:"max=128"` + ExpiredAt *time.Time `json:"expired_at"` } func ListStaffCredential(ctx *gin.Context) { listPage[models.StaffCredential](ctx) } @@ -25,7 +25,12 @@ func CreateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - credential := models.StaffCredential{Entity: newEntity("enabled"), StaffAccountID: request.StaffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + credential := models.StaffCredential{Entity: newEntity("enabled"), StaffAccountID: staffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} if err := impl.DBService.Create(&credential).Error; err != nil { infra.Response.Error(ctx, err) return @@ -38,15 +43,20 @@ func UpdateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": request.StaffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": staffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) } type userAddressRequest struct { - UserAccountID uint64 `json:"user_account_id" binding:"required"` - Address string `json:"address" binding:"required,max=255"` - Longitude string `json:"longitude" binding:"max=32"` - Latitude string `json:"latitude" binding:"max=32"` - IsDefault bool `json:"is_default"` + UserAccountIdentity string `json:"user_account_identity" binding:"required"` + Address string `json:"address" binding:"required,max=255"` + Longitude string `json:"longitude" binding:"max=32"` + Latitude string `json:"latitude" binding:"max=32"` + IsDefault bool `json:"is_default"` } func ListUserAddress(ctx *gin.Context) { listPage[models.UserAddress](ctx) } @@ -57,7 +67,12 @@ func CreateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - address := models.UserAddress{Entity: newEntity("enabled"), UserAccountID: request.UserAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + address := models.UserAddress{Entity: newEntity("enabled"), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} if err := impl.DBService.Create(&address).Error; err != nil { infra.Response.Error(ctx, err) return @@ -70,14 +85,19 @@ func UpdateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": request.UserAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) } type userServiceRelationRequest struct { - UserAccountID uint64 `json:"user_account_id" binding:"required"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - StaffAccountID uint64 `json:"staff_account_id"` + UserAccountIdentity string `json:"user_account_identity" binding:"required"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + StaffAccountIdentity string `json:"staff_account_identity"` } func ListUserServiceRelation(ctx *gin.Context) { listPage[models.UserServiceRelation](ctx) } @@ -88,7 +108,27 @@ func CreateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - relation := models.UserServiceRelation{Entity: newEntity("enabled"), UserAccountID: request.UserAccountID, GasBasicID: request.GasBasicID, DeliveryBasicID: request.DeliveryBasicID, StaffAccountID: request.StaffAccountID} + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + relation := models.UserServiceRelation{Entity: newEntity("enabled"), UserAccountID: userAccountID, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, StaffAccountID: staffAccountID} if err := impl.DBService.Create(&relation).Error; err != nil { infra.Response.Error(ctx, err) return @@ -101,5 +141,25 @@ func UpdateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"user_account_id": request.UserAccountID, "gas_basic_id": request.GasBasicID, "delivery_basic_id": request.DeliveryBasicID, "staff_account_id": request.StaffAccountID}, []string{"user_account_id", "gas_basic_id", "delivery_basic_id", "staff_account_id"}) + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"user_account_id": userAccountID, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "staff_account_id": staffAccountID}, []string{"user_account_id", "gas_basic_id", "delivery_basic_id", "staff_account_id"}) } diff --git a/backend/api/internal/logic/platform/staff.go b/backend/api/internal/logic/platform/staff.go index 0d484ef..bea9fa6 100644 --- a/backend/api/internal/logic/platform/staff.go +++ b/backend/api/internal/logic/platform/staff.go @@ -17,15 +17,15 @@ func GetStaff(ctx *gin.Context) { getByIdentity[models.StaffAccount](ctx) } // CreateStaff 创建服务人员档案。 func CreateStaff(ctx *gin.Context) { var request struct { - Username string `json:"username" binding:"required,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - WorkStatus string `json:"work_status" binding:"max=32"` + Username string `json:"username" binding:"required,max=64"` + Password string `json:"password" binding:"required,min=8,max=128"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar string `json:"avatar" binding:"max=512"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + WorkStatus string `json:"work_status" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -36,7 +36,17 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - staff := models.StaffAccount{Entity: newEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: request.GasBasicID, DeliveryBasicID: request.DeliveryBasicID, WorkStatus: request.WorkStatus} + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staff := models.StaffAccount{Entity: newEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, WorkStatus: request.WorkStatus} if staff.WorkStatus == "" { staff.WorkStatus = "off_duty" } @@ -50,17 +60,27 @@ func CreateStaff(ctx *gin.Context) { // UpdateStaff 更新服务人员档案。 func UpdateStaff(ctx *gin.Context) { var request struct { - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - WorkStatus string `json:"work_status" binding:"max=32"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar string `json:"avatar" binding:"max=512"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + WorkStatus string `json:"work_status" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": request.GasBasicID, "delivery_basic_id": request.DeliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) } diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index b4152d4..79899f9 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -1,8 +1,10 @@ package platform import ( + "bytes" "encoding/json" "errors" + "io" "reflect" "strings" @@ -33,6 +35,60 @@ func ResourceHandlers(model any, createFields, updateFields []string, relations 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())) @@ -126,15 +182,32 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela 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 { + id, err := resolveIdentityID(relation.Model, identity, true) + if err != nil { return nil, err } - values[relation.Column] = related.ID + 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 { @@ -266,3 +339,75 @@ func GetDeliveryTrack(ctx *gin.Context) { } infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points})) } + +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]) +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index f606e75..fb87267 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -68,7 +68,9 @@ func registerSafetyRoute(group *gin.RouterGroup) { } func registerCommerceRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/ec/ec_category", &models.EcCategory{}, []string{"name", "sort_no"}, optionalRelation("parent_identity", "parent_id", &models.EcCategory{})) + categoryRelations := []platform.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} + _, categoryCreate, _, categoryUpdate := platform.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) + registerWritableResource(group, "/ec/ec_category", platform.ListEcCategory, categoryCreate, platform.GetEcCategory, categoryUpdate, &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{})) @@ -106,7 +108,8 @@ func registerPlatformRoute(group *gin.RouterGroup) { func registerFinanceRoute(group *gin.RouterGroup) { registerRestrictedWritableResource(group, "/finance/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) - registerRestrictedWritableResource(group, "/finance/fin_settlement", &models.FinSettlement{}, []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}) + settlementList, settlementCreate, settlementGet, settlementUpdate := platform.FinSettlementHandlers() + registerWritableResource(group, "/finance/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) registerReadOnlyResource(group, "/wallet/wallet", &models.Wallet{}) diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 3d3f246..ce76172 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1195,6 +1195,8 @@ const contracts = [ } ]; const source = fs.readFileSync('src/api/resources.ts', 'utf8'); +const backendContractSource = fs.readFileSync('../../backend/api/internal/logic/platform/resource.go', 'utf8'); +const backendRouterSource = fs.readFileSync('../../backend/api/internal/routers/platform.go', 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const resourceModule = { exports: {} }; vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); @@ -1208,6 +1210,8 @@ function checkContracts() { const definition = resources.find((resource) => resource.name === contract.name); const label = `${contract.domain}/${contract.name}`; if (!definition || definition.resource !== contract.resource || definition.mode !== contract.mode || definition.pageKind !== contract.pageKind || !sameFields(definition.fields, contract.fields)) failures.push(`${label}: mismatched contract or allowlist`); + if (!backendContractSource.includes(`Domain: "${contract.domain}"`) || !backendContractSource.includes(`Name: "${contract.name}"`)) failures.push(`${label}: absent from backend ExpectedResources`); + if (contract.name !== 'saf_event_disposal' && !backendRouterSource.includes(contract.resource)) failures.push(`${label}: absent from backend router`); const requiredIdentity = contract.fields.filter((field) => field.required && field.key.endsWith('_identity')).map((field) => field.key); if (definition && JSON.stringify(definition.requiredIdentities ?? []) !== JSON.stringify(requiredIdentity)) failures.push(`${label}: required identity fields do not match allowlist`); if (contract.name === 'saf_event_disposal') continue; @@ -1224,7 +1228,7 @@ function checkContracts() { const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); if (/(CrudListPage|resourceApi\\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); - if (!treePage.includes(' resource.name === 'saf_event'); if (!safetyEvent?.detailActions?.some((item) => item.name === 'saf_event_disposal' && item.resource === '/safety/saf_event/:identity/disposals')) failures.push('safety event: missing disposal detail action'); diff --git a/frontend/platform_admin/src/views/shared/TreePage.vue b/frontend/platform_admin/src/views/shared/TreePage.vue index 9c1e2cb..bd05799 100644 --- a/frontend/platform_admin/src/views/shared/TreePage.vue +++ b/frontend/platform_admin/src/views/shared/TreePage.vue @@ -4,10 +4,10 @@ import { Message } from '@arco-design/web-vue'; import { computed, onMounted, ref } from 'vue'; import { resourceApi } from '@/api/resource'; import type { ResourceUiDefinition } from '@/api/resources'; -type Node = Record & { identity: string; parent_identity?: string; id?: number; parent_id?: number; children: Node[] }; +type Node = Record & { identity: string; parent_identity?: string; children: Node[] }; const props = defineProps<{ definition: ResourceUiDefinition }>(); const loading = ref(false); const list = ref([]); -const tree = computed(() => { const byIdentity = new Map(); const byInternalId = new Map(); const roots: Node[] = []; list.value.forEach((item) => { const node = { ...item, children: [] }; byIdentity.set(node.identity, node); if (typeof node.id === 'number') byInternalId.set(node.id, node); }); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : typeof item.parent_id === 'number' ? byInternalId.get(item.parent_id) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; }); +const tree = computed(() => { const byIdentity = new Map(); const roots: Node[] = []; list.value.forEach((item) => byIdentity.set(item.identity, { ...item, children: [] })); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; }); async function load() { loading.value = true; try { list.value = (await resourceApi.list(props.definition.resource, 1, 500)).list; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } } onMounted(load); From 40936190b1ea8c7005e687eb48949388812de3a1 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 10:25:04 +0800 Subject: [PATCH 09/17] fix: audit platform contracts and relation identities --- backend/api/cmd/resource-contract/main.go | 44 + .../api/internal/logic/platform/account.go | 4 +- .../api/internal/logic/platform/platform.go | 4 +- .../internal/logic/platform/resource_test.go | 23 + .../logic/platform/secondary_resource.go | 6 +- .../logic/platform/task4_resources.go | 115 +- .../platform_admin/scripts/audit-check.mjs | 1262 +---------------- frontend/platform_admin/src/api/resources.ts | 6 +- .../src/views/shared/TreePage.vue | 12 +- 9 files changed, 225 insertions(+), 1251 deletions(-) create mode 100644 backend/api/cmd/resource-contract/main.go diff --git a/backend/api/cmd/resource-contract/main.go b/backend/api/cmd/resource-contract/main.go new file mode 100644 index 0000000..1d9249c --- /dev/null +++ b/backend/api/cmd/resource-contract/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" + "git.apinb.com/heqiapp/platforms/backend/api/internal/routers" + "github.com/gin-gonic/gin" +) + +type route struct { + Method string `json:"method"` + Path string `json:"path"` +} +type contract struct { + Domain string `json:"domain"` + Name string `json:"name"` + PageKind string `json:"pageKind"` + Mode string `json:"mode"` +} +type manifest struct { + Resources []contract `json:"resources"` + Routes []route `json:"routes"` +} + +func main() { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + routers.RegisterPlatform("heqi", engine) + routes := make([]route, 0, len(engine.Routes())) + for _, item := range engine.Routes() { + routes = append(routes, route{Method: item.Method, Path: strings.TrimPrefix(item.Path, "/heqi/platform/v1")}) + } + expected := platform.ExpectedResources() + contracts := make([]contract, 0, len(expected)) + for _, item := range expected { + contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, PageKind: item.PageKind, Mode: string(item.Mode)}) + } + if err := json.NewEncoder(os.Stdout).Encode(manifest{Resources: contracts, Routes: routes}); err != nil { + panic(err) + } +} diff --git a/backend/api/internal/logic/platform/account.go b/backend/api/internal/logic/platform/account.go index 92fd00d..57fe39e 100644 --- a/backend/api/internal/logic/platform/account.go +++ b/backend/api/internal/logic/platform/account.go @@ -54,7 +54,7 @@ func CreateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, account) + infra.Response.Success(ctx, resourceResponse(account)) } func UpdateGasAccount(ctx *gin.Context) { @@ -95,7 +95,7 @@ func CreateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, account) + infra.Response.Success(ctx, resourceResponse(account)) } func UpdateDeliveryAccount(ctx *gin.Context) { diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index fe2be58..559442d 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -47,7 +47,7 @@ func listPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": list}) + infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list)}) } func getByIdentity[T any](ctx *gin.Context) { @@ -56,7 +56,7 @@ func getByIdentity[T any](ctx *gin.Context) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, data) + infra.Response.Success(ctx, publicResourceResponse(data)) } func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index ce897f0..547d8fb 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -261,6 +261,29 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { } } +func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}). + AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "identity" FROM "gas_basic" WHERE id = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). + WithArgs(uint64(7), 1). + WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("gas-a")) + + ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil) + ListGasAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"gas_basic_identity":"gas-a"`) || strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"id":`) { + t.Fatalf("account list did not return the public relation shape: %s", body) + } + assertMockExpectations(t, mock) +} + func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) now := time.Now().UTC() diff --git a/backend/api/internal/logic/platform/secondary_resource.go b/backend/api/internal/logic/platform/secondary_resource.go index d288a5c..0f8ec18 100644 --- a/backend/api/internal/logic/platform/secondary_resource.go +++ b/backend/api/internal/logic/platform/secondary_resource.go @@ -35,7 +35,7 @@ func CreateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, credential) + infra.Response.Success(ctx, resourceResponse(credential)) } func UpdateStaffCredential(ctx *gin.Context) { var request staffCredentialRequest @@ -77,7 +77,7 @@ func CreateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, address) + infra.Response.Success(ctx, resourceResponse(address)) } func UpdateUserAddress(ctx *gin.Context) { var request userAddressRequest @@ -133,7 +133,7 @@ func CreateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, relation) + infra.Response.Success(ctx, resourceResponse(relation)) } func UpdateUserServiceRelation(ctx *gin.Context) { var request userServiceRelationRequest diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index 79899f9..fe447b8 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -102,7 +102,7 @@ func listResource(ctx *gin.Context, model any) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list.Elem().Interface())}) + infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list.Elem().Interface())}) } func getResource(ctx *gin.Context, model any) { @@ -111,7 +111,7 @@ func getResource(ctx *gin.Context, model any) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, resourceResponse(data.Interface())) + infra.Response.Success(ctx, publicResourceResponse(data.Interface())) } func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { @@ -230,6 +230,113 @@ func resourceResponse(value any) any { 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 { + encoded, err := json.Marshal(value) + if err != nil { + return value + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return value + } + 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", +} + +func projectRelationIdentities(value any) any { + 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 { + data[identityKey] = relationIdentity(item, model) + } + delete(data, key) + continue + } + data[key] = projectRelationIdentities(item) + } + case []any: + for index := range data { + data[index] = projectRelationIdentities(data[index]) + } + } + return value +} + +func relationIdentity(value any, model any) string { + var id uint64 + switch raw := value.(type) { + case float64: + id = uint64(raw) + case uint64: + id = raw + case int: + id = uint64(raw) + } + if id == 0 { + return "" + } + var related struct{ Identity string } + if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil { + return "" + } + return related.Identity +} + +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: @@ -311,7 +418,7 @@ func GetEcOrder(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, resourceResponse(gin.H{"order": order, "items": items})) + infra.Response.Success(ctx, publicResourceResponse(gin.H{"order": order, "items": items})) } // GetDeliveryTrack returns time-ordered points. Precise coordinates are only @@ -337,7 +444,7 @@ func GetDeliveryTrack(ctx *gin.Context) { points[index].Latitude = "" } } - infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points})) + infra.Response.Success(ctx, publicResourceResponse(gin.H{"track": track, "points": points})) } type ecCategoryView struct { diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index ce76172..8b8a2b1 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,1245 +1,39 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import vm from 'node:vm'; import ts from 'typescript'; -const contracts = [ - { - "domain": "gas", - "name": "gas_basic", - "resource": "/gas/gas_basic", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "code", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "credit_code", - "required": false - }, - { - "key": "principal", - "required": false - }, - { - "key": "address", - "required": false - }, - { - "key": "longitude", - "required": false - }, - { - "key": "latitude", - "required": false - } - ] - }, - { - "domain": "gas", - "name": "gas_account", - "resource": "/gas/gas_account", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "username", - "required": true - }, - { - "key": "password", - "required": true - }, - { - "key": "display_name", - "required": false - }, - { - "key": "role_code", - "required": false - }, - { - "key": "gas_basic_identity", - "required": true - } - ] - }, - { - "domain": "delivery", - "name": "delivery_basic", - "resource": "/delivery/delivery_basic", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "delivery_code", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "gas_basic_identity", - "required": false - }, - { - "key": "principal", - "required": false - }, - { - "key": "address", - "required": false - } - ] - }, - { - "domain": "delivery", - "name": "delivery_account", - "resource": "/delivery/delivery_account", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "username", - "required": true - }, - { - "key": "password", - "required": true - }, - { - "key": "display_name", - "required": false - }, - { - "key": "role_code", - "required": false - }, - { - "key": "delivery_basic_identity", - "required": true - } - ] - }, - { - "domain": "delivery", - "name": "delivery_task", - "resource": "/delivery/delivery_task", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_order_identity", - "required": true - }, - { - "key": "delivery_basic_identity", - "required": true - }, - { - "key": "staff_account_identity", - "required": false - } - ] - }, - { - "domain": "delivery", - "name": "delivery_track", - "resource": "/delivery/delivery_track", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "delivery_task_identity", - "required": true - }, - { - "key": "started_at", - "required": false - }, - { - "key": "completed_at", - "required": false - } - ] - }, - { - "domain": "delivery", - "name": "delivery_track_point", - "resource": "/delivery/delivery_track_point", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "delivery_track_identity", - "required": true - }, - { - "key": "point_type", - "required": true - }, - { - "key": "occurred_at", - "required": true - }, - { - "key": "longitude", - "required": true - }, - { - "key": "latitude", - "required": true - } - ] - }, - { - "domain": "staff", - "name": "staff_account", - "resource": "/staff/account", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "username", - "required": true - }, - { - "key": "password", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "phone", - "required": false - }, - { - "key": "avatar", - "required": false - }, - { - "key": "role_code", - "required": false - }, - { - "key": "gas_basic_identity", - "required": false - }, - { - "key": "delivery_basic_identity", - "required": false - }, - { - "key": "work_status", - "required": false - } - ] - }, - { - "domain": "staff", - "name": "staff_credential", - "resource": "/staff/credential", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "staff_account_identity", - "required": true - }, - { - "key": "credential_type", - "required": true - }, - { - "key": "credential_no", - "required": false - }, - { - "key": "expired_at", - "required": false - } - ] - }, - { - "domain": "user", - "name": "user_account", - "resource": "/user/account", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "username", - "required": true - }, - { - "key": "password", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "phone", - "required": false - }, - { - "key": "avatar", - "required": false - }, - { - "key": "real_name", - "required": false - } - ] - }, - { - "domain": "user", - "name": "user_address", - "resource": "/user/address", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "address", - "required": true - }, - { - "key": "longitude", - "required": false - }, - { - "key": "latitude", - "required": false - }, - { - "key": "is_default", - "required": false - } - ] - }, - { - "domain": "user", - "name": "user_service_relation", - "resource": "/user/service_relation", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "gas_basic_identity", - "required": false - }, - { - "key": "delivery_basic_identity", - "required": false - }, - { - "key": "staff_account_identity", - "required": false - } - ] - }, - { - "domain": "device", - "name": "dev_smart_cylinder_valve", - "resource": "/device/dev_smart_cylinder_valve", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "device_no", - "required": true - }, - { - "key": "model", - "required": false - }, - { - "key": "online_status", - "required": false - }, - { - "key": "owner_identity", - "required": false - } - ] - }, - { - "domain": "device", - "name": "dev_device_binding", - "resource": "/device/dev_device_binding", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "smart_cylinder_valve_identity", - "required": true - }, - { - "key": "user_account_identity", - "required": true - }, - { - "key": "effective_at", - "required": false - }, - { - "key": "expired_at", - "required": false - } - ] - }, - { - "domain": "device", - "name": "dev_telemetry", - "resource": "/device/dev_telemetry", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "smart_cylinder_valve_identity", - "required": false - }, - { - "key": "recorded_at", - "required": false - }, - { - "key": "payload", - "required": false - } - ] - }, - { - "domain": "safety", - "name": "saf_rule", - "resource": "/safety/saf_rule", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "rule_code", - "required": true - }, - { - "key": "version_no", - "required": false - }, - { - "key": "threshold", - "required": false - }, - { - "key": "action", - "required": false - }, - { - "key": "gray_scope", - "required": false - } - ] - }, - { - "domain": "safety", - "name": "saf_event", - "resource": "/safety/saf_event", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "event_code", - "required": true - }, - { - "key": "level", - "required": true - }, - { - "key": "title", - "required": true - }, - { - "key": "smart_cylinder_valve_identity", - "required": true - }, - { - "key": "sla_at", - "required": false - } - ] - }, - { - "domain": "safety", - "name": "saf_inspection", - "resource": "/safety/saf_inspection", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "staff_account_identity", - "required": true - }, - { - "key": "result", - "required": true - }, - { - "key": "evidence_uri", - "required": false - } - ] - }, - { - "domain": "safety", - "name": "saf_event_disposal", - "resource": "/safety/saf_event/:identity/disposals", - "mode": "append_only", - "pageKind": "list", - "fields": [ - { - "key": "action", - "required": true - }, - { - "key": "reason", - "required": true - } - ] - }, - { - "domain": "ec", - "name": "ec_category", - "resource": "/ec/ec_category", - "mode": "writable", - "pageKind": "tree", - "fields": [ - { - "key": "parent_identity", - "required": false - }, - { - "key": "name", - "required": true - }, - { - "key": "sort_no", - "required": false - } - ] - }, - { - "domain": "ec", - "name": "ec_product", - "resource": "/ec/ec_product", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_category_identity", - "required": true - }, - { - "key": "product_code", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "price_amount", - "required": true - }, - { - "key": "stock_quantity", - "required": false - } - ] - }, - { - "domain": "ec", - "name": "ec_product_attribute", - "resource": "/ec/ec_product_attribute", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_product_identity", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "value", - "required": true - }, - { - "key": "sort_no", - "required": false - } - ] - }, - { - "domain": "ec", - "name": "ec_product_image", - "resource": "/ec/ec_product_image", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_product_identity", - "required": true - }, - { - "key": "image_uri", - "required": true - }, - { - "key": "sort_no", - "required": false - }, - { - "key": "is_cover", - "required": false - } - ] - }, - { - "domain": "ec", - "name": "ec_cart", - "resource": "/ec/ec_cart", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "ec_product_identity", - "required": true - }, - { - "key": "quantity", - "required": true - }, - { - "key": "selected", - "required": false - } - ] - }, - { - "domain": "ec", - "name": "ec_order", - "resource": "/ec/ec_order", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "gas_basic_identity", - "required": false - }, - { - "key": "delivery_basic_identity", - "required": false - }, - { - "key": "order_no", - "required": true - }, - { - "key": "total_amount", - "required": true - } - ] - }, - { - "domain": "ec", - "name": "ec_order_item", - "resource": "/ec/ec_order_item", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_order_identity", - "required": true - }, - { - "key": "ec_product_identity", - "required": true - }, - { - "key": "product_snapshot", - "required": true - }, - { - "key": "quantity", - "required": true - }, - { - "key": "sale_amount", - "required": true - } - ] - }, - { - "domain": "ec", - "name": "ec_review", - "resource": "/ec/ec_review", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_order_identity", - "required": true - }, - { - "key": "ec_product_identity", - "required": true - }, - { - "key": "user_account_identity", - "required": true - }, - { - "key": "score", - "required": true - }, - { - "key": "content", - "required": true - } - ] - }, - { - "domain": "finance", - "name": "fin_payment", - "resource": "/finance/fin_payment", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "ec_order_identity", - "required": true - }, - { - "key": "channel", - "required": true - }, - { - "key": "amount", - "required": true - }, - { - "key": "paid_at", - "required": false - } - ] - }, - { - "domain": "finance", - "name": "fin_settlement", - "resource": "/finance/fin_settlement", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "settlement_no", - "required": true - }, - { - "key": "subject_type", - "required": true - }, - { - "key": "subject_identity", - "required": true - }, - { - "key": "period_start", - "required": true - }, - { - "key": "period_end", - "required": true - } - ] - }, - { - "domain": "finance", - "name": "fin_reconciliation", - "resource": "/finance/fin_reconciliation", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "channel", - "required": true - }, - { - "key": "bill_date", - "required": true - }, - { - "key": "difference_amount", - "required": true - } - ] - }, - { - "domain": "content", - "name": "cnt_content", - "resource": "/content/cnt_content", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "content_type", - "required": true - }, - { - "key": "title", - "required": true - }, - { - "key": "body", - "required": true - }, - { - "key": "version_no", - "required": false - }, - { - "key": "publish_status", - "required": false - } - ] - }, - { - "domain": "notification", - "name": "ntf_template", - "resource": "/notification/ntf_template", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "template_code", - "required": true - }, - { - "key": "channel", - "required": true - }, - { - "key": "content", - "required": true - } - ] - }, - { - "domain": "customer_service", - "name": "cs_ticket", - "resource": "/customer_service/cs_ticket", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "user_account_identity", - "required": true - }, - { - "key": "ticket_no", - "required": true - }, - { - "key": "category", - "required": true - }, - { - "key": "priority", - "required": true - } - ] - }, - { - "domain": "platform", - "name": "platfrom_account", - "resource": "/platform/platfrom_account", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "username", - "required": true - }, - { - "key": "password", - "required": true - }, - { - "key": "display_name", - "required": false - }, - { - "key": "avatar", - "required": false - }, - { - "key": "platform_role_code", - "required": false - }, - { - "key": "phone", - "required": false - } - ] - }, - { - "domain": "platform", - "name": "platform_role", - "resource": "/platform/platform_role", - "mode": "writable", - "pageKind": "list", - "fields": [ - { - "key": "role_code", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "data_scope", - "required": true - } - ] - }, - { - "domain": "platform", - "name": "platform_menu", - "resource": "/platform/platform_menu", - "mode": "writable", - "pageKind": "tree", - "fields": [ - { - "key": "parent_identity", - "required": false - }, - { - "key": "menu_code", - "required": true - }, - { - "key": "name", - "required": true - }, - { - "key": "icon", - "required": false - }, - { - "key": "path", - "required": false - }, - { - "key": "sort_no", - "required": false - } - ] - }, - { - "domain": "wallet", - "name": "wallet", - "resource": "/wallet/wallet", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "owner_identity", - "required": false - }, - { - "key": "balance_amount", - "required": false - }, - { - "key": "status", - "required": false - } - ] - }, - { - "domain": "wallet", - "name": "wallet_ledger", - "resource": "/wallet/wallet_ledger", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "wallet_identity", - "required": false - }, - { - "key": "change_amount", - "required": false - }, - { - "key": "balance_after", - "required": false - } - ] - }, - { - "domain": "wallet", - "name": "wallet_recharge", - "resource": "/wallet/wallet_recharge", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "wallet_identity", - "required": false - }, - { - "key": "amount", - "required": false - }, - { - "key": "status", - "required": false - } - ] - }, - { - "domain": "wallet", - "name": "wallet_withdrawal", - "resource": "/wallet/wallet_withdrawal", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "wallet_identity", - "required": false - }, - { - "key": "amount", - "required": false - }, - { - "key": "status", - "required": false - } - ] - }, - { - "domain": "report", - "name": "report", - "resource": "/report/report", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "report_code", - "required": false - }, - { - "key": "name", - "required": false - }, - { - "key": "status", - "required": false - } - ] - }, - { - "domain": "report", - "name": "report_item", - "resource": "/report/report_item", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "report_identity", - "required": false - }, - { - "key": "metric_code", - "required": false - }, - { - "key": "value", - "required": false - } - ] - }, - { - "domain": "report", - "name": "report_metric_snapshot", - "resource": "/report/report_metric_snapshot", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "report_identity", - "required": false - }, - { - "key": "metric_code", - "required": false - }, - { - "key": "value", - "required": false - }, - { - "key": "captured_at", - "required": false - } - ] - }, - { - "domain": "audit", - "name": "aud_operation_log", - "resource": "/audit/aud_operation_log", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "operator_identity", - "required": false - }, - { - "key": "action", - "required": false - }, - { - "key": "object_identity", - "required": false - }, - { - "key": "created_at", - "required": false - } - ] - }, - { - "domain": "audit", - "name": "aud_export_log", - "resource": "/audit/aud_export_log", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "operator_identity", - "required": false - }, - { - "key": "resource_type", - "required": false - }, - { - "key": "created_at", - "required": false - } - ] - }, - { - "domain": "audit", - "name": "aud_approval", - "resource": "/audit/aud_approval", - "mode": "readonly", - "pageKind": "list", - "fields": [ - { - "key": "operator_identity", - "required": false - }, - { - "key": "status", - "required": false - }, - { - "key": "handled_at", - "required": false - } - ] - } -]; -const source = fs.readFileSync('src/api/resources.ts', 'utf8'); -const backendContractSource = fs.readFileSync('../../backend/api/internal/logic/platform/resource.go', 'utf8'); -const backendRouterSource = fs.readFileSync('../../backend/api/internal/routers/platform.go', 'utf8'); -const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; + +const backendDirectory = path.resolve('../..', 'backend/api'); +const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' })); +let responseShapeVerified = true; +try { + execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); +} catch { + responseShapeVerified = false; +} +const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const resourceModule = { exports: {} }; vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); const resources = resourceModule.exports.resources; const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : []; -const sameFields = (actual, expected) => Array.isArray(actual) && actual.length === expected.length && actual.every((field, index) => field.key === expected[index].key && Boolean(field.required) === Boolean(expected[index].required)); -function checkContracts() { - const failures = []; - if (!Array.isArray(resources) || resources.length !== contracts.length) failures.push(`catalogue: expected ${contracts.length} resources`); - for (const contract of contracts) { - const definition = resources.find((resource) => resource.name === contract.name); - const label = `${contract.domain}/${contract.name}`; - if (!definition || definition.resource !== contract.resource || definition.mode !== contract.mode || definition.pageKind !== contract.pageKind || !sameFields(definition.fields, contract.fields)) failures.push(`${label}: mismatched contract or allowlist`); - if (!backendContractSource.includes(`Domain: "${contract.domain}"`) || !backendContractSource.includes(`Name: "${contract.name}"`)) failures.push(`${label}: absent from backend ExpectedResources`); - if (contract.name !== 'saf_event_disposal' && !backendRouterSource.includes(contract.resource)) failures.push(`${label}: absent from backend router`); - const requiredIdentity = contract.fields.filter((field) => field.required && field.key.endsWith('_identity')).map((field) => field.key); - if (definition && JSON.stringify(definition.requiredIdentities ?? []) !== JSON.stringify(requiredIdentity)) failures.push(`${label}: required identity fields do not match allowlist`); - if (contract.name === 'saf_event_disposal') continue; - const route = path.join('src/router/routes/modules', contract.domain + '.ts'); - if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(contract.name)) failures.push(`${label}: missing route`); - const page = path.join('src/views', contract.domain, contract.name, contract.pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); - if (!fs.existsSync(page)) failures.push(`${label}: missing view`); - if (contract.mode === 'readonly' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); - } - const names = resources.map((resource) => resource.name).sort(); - const expectedNames = contracts.map((contract) => contract.name).sort(); - if (JSON.stringify(names) !== JSON.stringify(expectedNames)) failures.push('catalogue: resource names do not equal ExpectedResources'); - if (resources.some((resource) => resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')))) failures.push('catalogue: internal identifier leaked through fields'); - const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); - if (/(CrudListPage|resourceApi\\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); - const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); - if (!treePage.includes(' resource.name === 'saf_event'); - if (!safetyEvent?.detailActions?.some((item) => item.name === 'saf_event_disposal' && item.resource === '/safety/saf_event/:identity/disposals')) failures.push('safety event: missing disposal detail action'); - if (files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal')) || fs.existsSync('src/views/safety/saf_event_disposal')) failures.push('saf_event_disposal: must not have independent menu or page'); - const crudSource = fs.readFileSync('src/views/shared/CrudListPage.vue', 'utf8'); - if (!crudSource.includes("key !== 'id'") || !crudSource.includes("key.endsWith('_id')")) failures.push('detail view: does not remove internal identifiers'); - const leakedUiFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue') && !file.endsWith('TreePage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); - if (leakedUiFiles.length) failures.push(`identity leak: ${leakedUiFiles.join(', ')}`); - if (/data-index="(?:id|[^" ]+_id)"|\{\{\s*[^}]*\b(?:id|[A-Za-z][A-Za-z0-9]*_id)\b/.test(treePage)) failures.push('tree page: displays internal identifier'); - return failures; +const failures = []; +if (!responseShapeVerified) failures.push('backend list response shape is not identity-only'); +const expected = manifest.resources; +if (resources.length !== expected.length) failures.push(`catalogue count: ${resources.length}/${expected.length}`); +for (const contract of expected) { + const resource = resources.find((item) => item.name === contract.name); + const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind; + if (!resource || resource.mode !== contract.mode || resource.pageKind !== pageKind) { failures.push(`${contract.name}: contract mismatch`); continue; } + if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${contract.name}: missing Chinese business labels`); + if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`); + const method = resource.mode === 'append_only' ? 'POST' : 'GET'; + if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`); + if (resource.mode === 'readonly') { const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue')); if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`); } } -const failures = checkContracts(); +if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources'); +for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); } +const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); +if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing'); for (const failure of failures) console.log(failure); if (failures.length) process.exitCode = 1; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 1c09dd4..f90c0b2 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -4,10 +4,12 @@ export type ResourceField = { key: string; label: string; required?: boolean }; export type DetailAction = { name: string; resource: string; fields: ResourceField[] }; export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; requiredIdentities: string[]; detailActions?: DetailAction[] }; -const field = (value: string): ResourceField => ({ key: value.replace(/!$/, ''), label: value.replace(/!$/, ''), required: value.endsWith('!') || undefined }); +const labels: Record = { code: '编码', name: '名称', credit_code: '统一信用代码', principal: '负责人', address: '地址', longitude: '经度', latitude: '纬度', username: '用户名', password: '密码', display_name: '显示名称', role_code: '角色编码', delivery_code: '配送编码', phone: '联系电话', avatar: '头像', work_status: '工作状态', credential_type: '资质类型', credential_no: '资质编号', expired_at: '到期时间', real_name: '实名姓名', is_default: '默认地址', device_no: '设备编号', model: '设备型号', online_status: '在线状态', effective_at: '生效时间', recorded_at: '采集时间', payload: '遥测数据', rule_code: '规则编码', version_no: '版本号', threshold: '阈值', action: '处置动作', gray_scope: '灰度范围', event_code: '事件编码', level: '事件等级', title: '标题', sla_at: '处置时限', result: '检查结果', evidence_uri: '凭证地址', reason: '处置原因', sort_no: '排序号', product_code: '商品编码', price_amount: '售价', stock_quantity: '库存', value: '属性值', image_uri: '图片地址', is_cover: '封面图', quantity: '数量', selected: '是否选中', order_no: '订单号', total_amount: '订单金额', product_snapshot: '商品快照', sale_amount: '成交金额', score: '评分', channel: '渠道', amount: '金额', paid_at: '支付时间', settlement_no: '结算单号', subject_type: '结算对象类型', period_start: '结算开始时间', period_end: '结算结束时间', bill_date: '账单日期', difference_amount: '差异金额', content_type: '内容类型', body: '正文', publish_status: '发布状态', template_code: '模板编码', content: '内容', ticket_no: '工单号', category: '分类', priority: '优先级', platform_role_code: '平台角色', data_scope: '数据范围', menu_code: '菜单编码', icon: '图标', path: '路径', balance_amount: '余额', change_amount: '变动金额', balance_after: '变动后余额', report_code: '报表编码', metric_code: '指标编码', captured_at: '采集时间', operator_identity: '操作人标识', resource_type: '资源类型', handled_at: '处理时间', status: '状态' }; +const field = (value: string): ResourceField => { const key = value.replace(/!$/, ''); return { key, label: labels[key] ?? (key.endsWith('_identity') ? '关联业务标识' : '业务字段'), required: value.endsWith('!') || undefined }; }; +const titles: Record = { gas_basic: '气站管理', gas_account: '气站账户', delivery_basic: '配送点管理', delivery_account: '配送账户', delivery_task: '配送任务', delivery_track: '配送轨迹', delivery_track_point: '轨迹点', staff_account: '服务人员', staff_credential: '人员资质', user_account: '用户账户', user_address: '用户地址', user_service_relation: '用户服务关系', dev_smart_cylinder_valve: '智能钢瓶阀', dev_device_binding: '设备绑定', dev_telemetry: '设备遥测', saf_rule: '安全规则', saf_event: '安全事件', saf_inspection: '安全检查', saf_event_disposal: '事件处置', ec_category: '商品分类', ec_product: '商品管理', ec_product_attribute: '商品属性', ec_product_image: '商品图片', ec_cart: '购物车', ec_order: '订单管理', ec_order_item: '订单明细', ec_review: '商品评价', fin_payment: '支付记录', fin_settlement: '财务结算', fin_reconciliation: '财务对账', cnt_content: '内容管理', ntf_template: '通知模板', cs_ticket: '客服工单', platfrom_account: '平台账户', platform_role: '平台角色', platform_menu: '平台菜单', wallet: '钱包', wallet_ledger: '钱包流水', wallet_recharge: '钱包充值', wallet_withdrawal: '钱包提现', report: '报表', report_item: '报表项目', report_metric_snapshot: '指标快照', aud_operation_log: '操作审计', aud_export_log: '导出审计', aud_approval: '审批审计' }; const define = (name: string, resource: string, mode: ResourceMode, pageKind: ResourcePageKind, keys: string[], detailActions?: DetailAction[]): ResourceUiDefinition => { const fields = keys.map(field); - return { key: name.replace(/_/g, '-'), name, resource, title: name, mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) }; + return { key: name.replace(/_/g, '-'), name, resource, title: titles[name] ?? '业务资源', mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) }; }; const action = (name: string, resource: string, keys: string[]): DetailAction => ({ name, resource, fields: keys.map(field) }); diff --git a/frontend/platform_admin/src/views/shared/TreePage.vue b/frontend/platform_admin/src/views/shared/TreePage.vue index bd05799..c8d05b2 100644 --- a/frontend/platform_admin/src/views/shared/TreePage.vue +++ b/frontend/platform_admin/src/views/shared/TreePage.vue @@ -1,13 +1,17 @@ - + From 0616cecf2c9dfa020fdcecefbd71f0aab3ce26c6 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 10:42:16 +0800 Subject: [PATCH 10/17] fix: harden platform response projections --- .../api/internal/logic/platform/platform.go | 14 +- .../internal/logic/platform/resource_test.go | 74 ++++++++++- .../logic/platform/task4_resources.go | 120 ++++++++++++++---- .../platform_admin/scripts/audit-check.mjs | 16 ++- 4 files changed, 195 insertions(+), 29 deletions(-) diff --git a/backend/api/internal/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index 559442d..5235a8c 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -47,7 +47,12 @@ func listPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list)}) + response, err := publicResourceResponse(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": response}) } func getByIdentity[T any](ctx *gin.Context) { @@ -56,7 +61,12 @@ func getByIdentity[T any](ctx *gin.Context) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, publicResourceResponse(data)) + response, err := publicResourceResponse(data) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) } func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index 547d8fb..f970de7 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -173,12 +173,27 @@ func TestGetEcOrderReturnsOrderItems(t *testing.T) { WithArgs(uint64(1)). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}). AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500))) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_basic" WHERE id IN ($1)`)). + WithArgs(uint64(4)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(4), "delivery-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_order" WHERE id IN ($1)`)). + WithArgs(uint64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(1), "order-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "ec_product" WHERE id IN ($1)`)). + WithArgs(uint64(5)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(5), "product-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). + WithArgs(uint64(3)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(3), "gas-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "user_account" WHERE id IN ($1)`)). + WithArgs(uint64(2)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(2), "user-a")) ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil) GetEcOrder(ctx) assertResponseCode(t, recorder, 0) - if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) { + if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) || strings.Contains(recorder.Body.String(), `_id"`) { t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) } assertMockExpectations(t, mock) @@ -269,9 +284,9 @@ func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *test WithArgs(20). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}). AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin")) - mock.ExpectQuery(regexp.QuoteMeta(`SELECT "identity" FROM "gas_basic" WHERE id = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). - WithArgs(uint64(7), 1). - WillReturnRows(sqlmock.NewRows([]string{"identity"}).AddRow("gas-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a")) ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil) ListGasAccount(ctx) @@ -284,6 +299,51 @@ func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *test assertMockExpectations(t, mock) } +func TestListGasAccountPreloadsRelationIdentitiesInOneQuery(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}). + AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator-a", "Operator A", "hash", "admin"). + AddRow(uint64(10), "account-b", nil, nil, "enabled", 1, uint64(8), "operator-b", "Operator B", "hash", "admin")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1,$2)`)). + WithArgs(uint64(7), uint64(8)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "gas-a").AddRow(uint64(8), "gas-b")) + + ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil) + ListGasAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"gas_basic_identity":"gas-a"`) || !strings.Contains(body, `"gas_basic_identity":"gas-b"`) { + t.Fatalf("account list omitted preloaded relation identities: %s", body) + } + assertMockExpectations(t, mock) +} + +func TestListGasAccountFailsWhenRelationIdentityProjectionCannotLoad(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"}). + AddRow(uint64(9), "account-a", nil, nil, "enabled", 1, uint64(7), "operator", "Operator", "hash", "admin")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). + WithArgs(uint64(7)). + WillReturnError(errors.New("relation lookup unavailable")) + + ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account", "", nil) + ListGasAccount(ctx) + + if strings.Contains(recorder.Body.String(), `"code":0`) { + t.Fatalf("relation projection failure was returned as success: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) now := time.Now().UTC() @@ -295,6 +355,12 @@ func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *test WithArgs(uint64(7)). WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}). AddRow(uint64(9), "point-a", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_task" WHERE id IN ($1)`)). + WithArgs(uint64(8)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "task-a")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "delivery_track" WHERE id IN ($1)`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "track-a")) ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil) GetDeliveryTrack(ctx) diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index fe447b8..e90c3de 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -6,6 +6,7 @@ import ( "errors" "io" "reflect" + "sort" "strings" "git.apinb.com/bsm-sdk/core/errcode" @@ -102,7 +103,12 @@ func listResource(ctx *gin.Context, model any) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": publicResourceResponse(list.Elem().Interface())}) + 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) { @@ -111,7 +117,12 @@ func getResource(ctx *gin.Context, model any) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, publicResourceResponse(data.Interface())) + 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) { @@ -233,14 +244,14 @@ func resourceResponse(value any) any { // 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 { +func publicResourceResponse(value any) (any, error) { encoded, err := json.Marshal(value) if err != nil { - return value + return nil, err } var decoded any if err := json.Unmarshal(encoded, &decoded); err != nil { - return value + return nil, err } return projectRelationIdentities(decoded) } @@ -269,7 +280,54 @@ var relationIdentityKeys = map[string]string{ "delivery_point_id": "delivery_basic_identity", } -func projectRelationIdentities(value any) any { +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 { @@ -288,22 +346,35 @@ func projectRelationIdentities(value any) any { identityKey = "subject_identity" } if model != nil { - data[identityKey] = relationIdentity(item, model) + 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 } - data[key] = projectRelationIdentities(item) + collectRelationIdentityReferences(item, groups) } case []any: - for index := range data { - data[index] = projectRelationIdentities(data[index]) + for _, item := range data { + collectRelationIdentityReferences(item, groups) } } - return value } -func relationIdentity(value any, model any) string { +func responseRelationID(value any) (uint64, bool) { var id uint64 switch raw := value.(type) { case float64: @@ -312,15 +383,10 @@ func relationIdentity(value any, model any) string { id = raw case int: id = uint64(raw) + default: + return 0, false } - if id == 0 { - return "" - } - var related struct{ Identity string } - if err := impl.DBService.Model(model).Select("identity").Where("id = ?", id).First(&related).Error; err != nil { - return "" - } - return related.Identity + return id, true } func settlementSubjectModel(value any) any { @@ -418,7 +484,12 @@ func GetEcOrder(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, publicResourceResponse(gin.H{"order": order, "items": items})) + 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 @@ -444,7 +515,12 @@ func GetDeliveryTrack(ctx *gin.Context) { points[index].Latitude = "" } } - infra.Response.Success(ctx, publicResourceResponse(gin.H{"track": track, "points": points})) + 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 { diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 8b8a2b1..2f54eee 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -29,11 +29,25 @@ for (const contract of expected) { if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`); const method = resource.mode === 'append_only' ? 'POST' : 'GET'; if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`); - if (resource.mode === 'readonly') { const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue')); if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`); } + if (resource.mode === 'readonly') { + const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue')); + if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`); + } } if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources'); for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); } const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing'); +if (/(?:['"`]id['"`]|\bparent_id\b|\.id\b)/.test(tree)) failures.push('tree: internal id key leaked'); +const readOnlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); +if (/resourceApi\.(?:create|update|archive)\b/.test(readOnlyPage)) failures.push('readonly: mutation action exposed'); +for (const detailPage of ['src/views/shared/CrudListPage.vue', 'src/views/shared/ReadOnlyListPage.vue']) { + const source = fs.readFileSync(detailPage, 'utf8'); + if (!source.includes("key !== 'id'") || !source.includes("key.endsWith('_id')")) failures.push(`${detailPage}: internal ids shown in detail`); +} +const event = resources.find((resource) => resource.name === 'saf_event'); +const disposal = resources.find((resource) => resource.name === 'saf_event_disposal'); +if (!event?.detailActions?.some((action) => action.name === 'saf_event_disposal' && action.resource.includes(':identity/disposals'))) failures.push('saf_event_disposal: detail action missing'); +if (!disposal || disposal.resource !== '/safety/saf_event/:identity/disposals' || files('src/views').some((file) => file.includes('saf_event_disposal'))) failures.push('saf_event_disposal: independent page exposed'); for (const failure of failures) console.log(failure); if (failures.length) process.exitCode = 1; From 2919961edac14201fcdca8475024965eecf74a8e Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 11:00:21 +0800 Subject: [PATCH 11/17] fix: complete platform admin audit remediation --- backend/api/cmd/resource-contract/main.go | 3 +- .../api/internal/logic/platform/resource.go | 84 +++++------ backend/api/internal/routers/platform_test.go | 27 ++++ docs/平台总后台审计报告-2026-07-27.md | 54 +++++++ .../platform_admin/scripts/audit-check.mjs | 142 ++++++++++++------ .../scripts/audit-check.test.mjs | 48 ++++++ 6 files changed, 268 insertions(+), 90 deletions(-) create mode 100644 docs/平台总后台审计报告-2026-07-27.md create mode 100644 frontend/platform_admin/scripts/audit-check.test.mjs diff --git a/backend/api/cmd/resource-contract/main.go b/backend/api/cmd/resource-contract/main.go index 1d9249c..8c07334 100644 --- a/backend/api/cmd/resource-contract/main.go +++ b/backend/api/cmd/resource-contract/main.go @@ -17,6 +17,7 @@ type route struct { type contract struct { Domain string `json:"domain"` Name string `json:"name"` + Path string `json:"path"` PageKind string `json:"pageKind"` Mode string `json:"mode"` } @@ -36,7 +37,7 @@ func main() { expected := platform.ExpectedResources() contracts := make([]contract, 0, len(expected)) for _, item := range expected { - contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, PageKind: item.PageKind, Mode: string(item.Mode)}) + contracts = append(contracts, contract{Domain: item.Domain, Name: item.Name, Path: item.Path, PageKind: item.PageKind, Mode: string(item.Mode)}) } if err := json.NewEncoder(os.Stdout).Encode(manifest{Resources: contracts, Routes: routes}); err != nil { panic(err) diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index e26206f..816f8e1 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -19,6 +19,7 @@ const ( type ResourceContract struct { Domain string Name string + Path string PageKind string Mode ResourceMode } @@ -69,51 +70,42 @@ func filterFields(values map[string]any, allowedFields []string) gin.H { // ExpectedResources returns the complete platform-admin resource catalogue. func ExpectedResources() []ResourceContract { return []ResourceContract{ - {Domain: "gas", Name: "gas_basic", Mode: Writable, PageKind: "list"}, - {Domain: "gas", Name: "gas_account", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_basic", Mode: Writable, PageKind: "list"}, - {Domain: "delivery", Name: "delivery_account", Mode: Writable, PageKind: "list"}, - {Domain: "staff", Name: "staff_account", Mode: Writable, PageKind: "list"}, - {Domain: "staff", Name: "staff_credential", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_account", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_address", Mode: Writable, PageKind: "list"}, - {Domain: "user", Name: "user_service_relation", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "dev_smart_cylinder_valve", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "dev_device_binding", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_rule", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_event", Mode: Writable, PageKind: "list"}, - {Domain: "safety", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_category", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_product", Mode: Writable, PageKind: "list"}, - {Domain: "ec", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, - {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"}, - {Domain: "delivery", Name: "delivery_track_point", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_payment", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_settlement", Mode: Writable, PageKind: "list"}, - {Domain: "finance", Name: "fin_reconciliation", Mode: Writable, PageKind: "list"}, - {Domain: "content", Name: "cnt_content", Mode: Writable, PageKind: "list"}, - {Domain: "notification", Name: "ntf_template", Mode: Writable, PageKind: "list"}, - {Domain: "customer_service", Name: "cs_ticket", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platfrom_account", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platform_role", Mode: Writable, PageKind: "list"}, - {Domain: "platform", Name: "platform_menu", Mode: Writable, PageKind: "tree"}, - {Domain: "device", Name: "dev_telemetry", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_ledger", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_recharge", Mode: ReadOnly, PageKind: "list"}, - {Domain: "wallet", Name: "wallet_withdrawal", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report_item", Mode: ReadOnly, PageKind: "list"}, - {Domain: "report", Name: "report_metric_snapshot", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_operation_log", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_export_log", Mode: ReadOnly, PageKind: "list"}, - {Domain: "audit", Name: "aud_approval", Mode: ReadOnly, PageKind: "list"}, - {Domain: "safety", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, + resourceContract("gas", "gas_basic", Writable, "list"), resourceContract("gas", "gas_account", Writable, "list"), + resourceContract("delivery", "delivery_basic", Writable, "list"), resourceContract("delivery", "delivery_account", Writable, "list"), + resourceContract("staff", "staff_account", Writable, "list"), resourceContract("staff", "staff_credential", Writable, "list"), + resourceContract("user", "user_account", Writable, "list"), resourceContract("user", "user_address", Writable, "list"), resourceContract("user", "user_service_relation", Writable, "list"), + resourceContract("device", "dev_smart_cylinder_valve", Writable, "list"), resourceContract("device", "dev_device_binding", Writable, "list"), resourceContract("device", "dev_telemetry", ReadOnly, "list"), + resourceContract("safety", "saf_rule", Writable, "list"), resourceContract("safety", "saf_event", Writable, "list"), resourceContract("safety", "saf_inspection", Writable, "list"), resourceContract("safety", "saf_event_disposal", AppendOnly, "list"), + resourceContract("ec", "ec_category", Writable, "list"), resourceContract("ec", "ec_product", Writable, "list"), resourceContract("ec", "ec_product_attribute", Writable, "list"), resourceContract("ec", "ec_product_image", Writable, "list"), resourceContract("ec", "ec_cart", Writable, "list"), resourceContract("ec", "ec_order", Writable, "list"), resourceContract("ec", "ec_order_item", Writable, "list"), resourceContract("ec", "ec_review", Writable, "list"), + resourceContract("delivery", "delivery_task", Writable, "list"), resourceContract("delivery", "delivery_track", Writable, "list"), resourceContract("delivery", "delivery_track_point", Writable, "list"), + resourceContract("finance", "fin_payment", Writable, "list"), resourceContract("finance", "fin_settlement", Writable, "list"), resourceContract("finance", "fin_reconciliation", Writable, "list"), + resourceContract("content", "cnt_content", Writable, "list"), resourceContract("notification", "ntf_template", Writable, "list"), resourceContract("customer_service", "cs_ticket", Writable, "list"), + resourceContract("platform", "platfrom_account", Writable, "list"), resourceContract("platform", "platform_role", Writable, "list"), resourceContract("platform", "platform_menu", Writable, "tree"), + resourceContract("wallet", "wallet", ReadOnly, "list"), resourceContract("wallet", "wallet_ledger", ReadOnly, "list"), resourceContract("wallet", "wallet_recharge", ReadOnly, "list"), resourceContract("wallet", "wallet_withdrawal", ReadOnly, "list"), + resourceContract("report", "report", ReadOnly, "list"), resourceContract("report", "report_item", ReadOnly, "list"), resourceContract("report", "report_metric_snapshot", ReadOnly, "list"), + resourceContract("audit", "aud_operation_log", ReadOnly, "list"), resourceContract("audit", "aud_export_log", ReadOnly, "list"), resourceContract("audit", "aud_approval", ReadOnly, "list"), + } +} + +func resourceContract(domain, name string, mode ResourceMode, pageKind string) ResourceContract { + return ResourceContract{Domain: domain, Name: name, Path: resourcePath(domain, name), Mode: mode, PageKind: pageKind} +} + +func resourcePath(domain, name string) string { + switch name { + case "staff_account": + return "/staff/account" + case "staff_credential": + return "/staff/credential" + case "user_account": + return "/user/account" + case "user_address": + return "/user/address" + case "user_service_relation": + return "/user/service_relation" + case "saf_event_disposal": + return "/safety/saf_event/:identity/disposals" + default: + return "/" + domain + "/" + name } } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 22d096e..e8f4d3d 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -4,9 +4,36 @@ import ( "net/http" "testing" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" "github.com/gin-gonic/gin" ) +func TestEveryContractHasRegisteredRoute(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + for _, contract := range platform.ExpectedResources() { + path := "/heqi/platform/v1" + contract.Path + switch contract.Mode { + case platform.ReadOnly: + assertRouteMethods(t, routes, path, http.MethodGet) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + case platform.AppendOnly: + assertRouteMethods(t, routes, path, http.MethodPost) + default: + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) + } + } +} + func TestPlatformGasRouteUsesGasBasic(t *testing.T) { engine := gin.New() RegisterPlatform("heqi", engine) diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md new file mode 100644 index 0000000..df70661 --- /dev/null +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -0,0 +1,54 @@ +# 平台总后台审计报告(2026-07-27) + +## 范围与结论 + +本次审计覆盖 46 个平台后台资源:34 个可写资源、11 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing ` 失败。 + +## 发现与处置 + +| 发现 | 处置 | 验证 | +| --- | --- | --- | +| 只读页面的静态检查未覆盖状态变更调用 | 将 `resourceApi.updateStatus` 纳入只读页的变更操作集合,同时覆盖 create、update、archive | `audit-check.test.mjs` 的只读页用例 | +| 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` | +| 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 | +| API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` | + +## 身份字段与保留理由 + +- 对外 HTTP 仅使用 `identity` 及关联的 `*_identity`;数据库主键 `id` 和关系外键 `*_id` 仍用于 GORM 关联、索引和内部查询,并由响应投影剔除,不能删除。 +- `status`、`version`、`created_at`、`updated_at` 是归档、乐观并发和审计所需实体元数据,予以保留。 +- `product_snapshot`、`report_metric_snapshot` 保留历史商品及报表快照,避免后续主数据变更影响历史记录。 +- 审计的 `before_data`、`after_data`、`operator_identity`,以及审批的 `handler_identity`、`handled_at` 用于追踪处置人和决策,不可作为冗余清除。 + +## 冗余清理 + +未删除模型字段或页面。审计没有找到同时满足“不在需求、无后端调用、无前端消费者、无测试或迁移依赖”的候选项;为避免移除潜在兼容入口,保留现有非契约文件,且不将它们纳入菜单与资源契约。 + +## 验证命令 + +以下命令在提交前执行: + +```powershell +cd backend/api +go test ./... +go build ./cmd/main +go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v + +cd ../../frontend/platform_admin +node --test scripts/audit-check.test.mjs +pnpm type:check +pnpm lint +pnpm audit:platform +pnpm build +``` + +| 命令 | 退出码 | 结果 | +| --- | --- | --- | +| `go test ./...` | 0 | 通过 | +| `go build ./cmd/main` | 0 | 通过 | +| `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 | +| `node --test scripts/audit-check.test.mjs` | 0 | 通过,3 个审计用例 | +| `pnpm type:check` | 0 | 通过 | +| `pnpm audit:platform` | 0 | 通过 | +| `pnpm build` | 0 | 通过 | +| `pnpm lint` | 1 | 阻塞:Biome 配置在本目录要求 `.gitignore`。以 `--vcs-root=../..` 启动后发现 187 个既有全仓格式/未使用变量错误;未将无关全仓格式化混入本次审计修复。 | diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 2f54eee..c72ec8e 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -4,50 +4,106 @@ import path from 'node:path'; import vm from 'node:vm'; import ts from 'typescript'; -const backendDirectory = path.resolve('../..', 'backend/api'); -const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' })); -let responseShapeVerified = true; -try { - execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); -} catch { - responseShapeVerified = false; -} -const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; -const resourceModule = { exports: {} }; -vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); -const resources = resourceModule.exports.resources; const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : []; -const failures = []; -if (!responseShapeVerified) failures.push('backend list response shape is not identity-only'); -const expected = manifest.resources; -if (resources.length !== expected.length) failures.push(`catalogue count: ${resources.length}/${expected.length}`); -for (const contract of expected) { - const resource = resources.find((item) => item.name === contract.name); - const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind; - if (!resource || resource.mode !== contract.mode || resource.pageKind !== pageKind) { failures.push(`${contract.name}: contract mismatch`); continue; } - if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${contract.name}: missing Chinese business labels`); - if (resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id'))) failures.push(`${contract.name}: invalid allowlist`); - const method = resource.mode === 'append_only' ? 'POST' : 'GET'; - if (!manifest.routes.some((route) => route.method === method && route.path === resource.resource)) failures.push(`${contract.name}: resource route mismatch`); - if (resource.mode === 'readonly') { - const view = files('src/views').find((file) => file.includes(resource.name) && file.endsWith('ListPage.vue')); - if (!view || !fs.readFileSync(view, 'utf8').includes('ReadOnlyListPage')) failures.push(`${contract.name}: readonly UI`); +const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).filter((file) => extensions.includes(path.extname(file))).map((file) => [file.replaceAll('\\', '/'), fs.readFileSync(file, 'utf8')])); +const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]); + +function requiredBackendRoutes(contract) { + if (contract.mode === 'append_only') return [{ method: 'POST', path: contract.path }]; + const resource = contract.path; + const detail = `${resource}/:identity`; + if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }]; + return [ + { method: 'GET', path: resource }, { method: 'POST', path: resource }, { method: 'GET', path: detail }, + { method: 'PUT', path: detail }, { method: 'PATCH', path: `${detail}/status` }, { method: 'DELETE', path: detail }, + ]; +} + +function routeCoverage(contract, routeSources, viewSources) { + const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`); + let hasPage = false; + let hasMenu = false; + for (const source of routeSources) { + for (const match of source.matchAll(/component:\s*\(\)\s*=>\s*import\(['\"]@\/views\/([^'\"]+)['\"]\)([\s\S]{0,260}?meta:\s*\{[^}]*\})?/g)) { + const view = viewSources.get(`src/views/${match[1]}`); + if (!view || !expectedView.test(view)) continue; + hasPage = true; + if (/locale:\s*['\"]menu\.platform\./.test(match[2] ?? '')) hasMenu = true; + } } + return { hasPage, hasMenu }; } -if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(expected.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources'); -for (const name of ['ec_category', 'platform_menu']) { const view = files('src/views').find((file) => file.includes(name) && file.endsWith('TreePage.vue')); if (!view) failures.push(`${name}: tree page missing`); } -const tree = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); -if (!tree.includes('parent_identity') || !tree.includes('canWrite') || !tree.includes('resourceApi.create') || !tree.includes('resourceApi.update')) failures.push('tree: writable identity actions missing'); -if (/(?:['"`]id['"`]|\bparent_id\b|\.id\b)/.test(tree)) failures.push('tree: internal id key leaked'); -const readOnlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); -if (/resourceApi\.(?:create|update|archive)\b/.test(readOnlyPage)) failures.push('readonly: mutation action exposed'); -for (const detailPage of ['src/views/shared/CrudListPage.vue', 'src/views/shared/ReadOnlyListPage.vue']) { - const source = fs.readFileSync(detailPage, 'utf8'); - if (!source.includes("key !== 'id'") || !source.includes("key.endsWith('_id')")) failures.push(`${detailPage}: internal ids shown in detail`); + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -const event = resources.find((resource) => resource.name === 'saf_event'); -const disposal = resources.find((resource) => resource.name === 'saf_event_disposal'); -if (!event?.detailActions?.some((action) => action.name === 'saf_event_disposal' && action.resource.includes(':identity/disposals'))) failures.push('saf_event_disposal: detail action missing'); -if (!disposal || disposal.resource !== '/safety/saf_event/:identity/disposals' || files('src/views').some((file) => file.includes('saf_event_disposal'))) failures.push('saf_event_disposal: independent page exposed'); -for (const failure of failures) console.log(failure); -if (failures.length) process.exitCode = 1; + +/** Scans user-facing API and view sources for auto-increment primary or relation IDs. */ +export function scanInternalIdLeaks(sources) { + const failures = []; + for (const [file, source] of sources) { + for (const line of source.split(/\r?\n/)) { + // These are defensive filters that explicitly remove IDs from details, not leaks. + if (line.includes("key !== 'id'") || line.includes("key.endsWith('_id')")) continue; + const relation = line.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/); + if (relation) failures.push(`${file}: internal identifier ${relation[1]}`); + else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(line)) failures.push(`${file}: internal identifier id`); + } + } + return failures; +} + +/** Evaluates every platform contract against its backend route, UI definition, page and menu route. */ +export function auditPlatform({ manifest, resources, readOnlyPage, routeSources, viewSources, apiSources, responseShapeVerified = true }) { + const failures = []; + if (!responseShapeVerified) failures.push('backend list response shape is not identity-only'); + if (resources.length !== manifest.resources.length) failures.push(`catalogue count: ${resources.length}/${manifest.resources.length}`); + for (const contract of manifest.resources) { + const resource = resources.find((item) => item.name === contract.name); + const pageKind = contract.name === 'ec_category' ? 'tree' : contract.pageKind; + const label = `${contract.domain}/${contract.name}`; + if (!resource || resource.resource !== contract.path || resource.mode !== contract.mode || resource.pageKind !== pageKind) { + failures.push(`${label}: missing frontend resource`); + continue; + } + if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`); + for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`); + if (contract.mode === 'append_only') continue; + const coverage = routeCoverage(contract, routeSources, viewSources); + if (!coverage.hasPage) failures.push(`${label}: missing page`); + if (!coverage.hasMenu) failures.push(`${label}: missing menu route`); + } + if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(manifest.resources.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources'); + const readonlyMutations = mutationActions(readOnlyPage); + if (readonlyMutations.length) failures.push(`readonly: mutation action exposed (${readonlyMutations.join(', ')})`); + failures.push(...scanInternalIdLeaks(new Map([...apiSources, ...viewSources]))); + return failures; +} + +function loadResources() { + const compiled = ts.transpileModule(fs.readFileSync('src/api/resources.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; + const resourceModule = { exports: {} }; + vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); + return resourceModule.exports.resources; +} + +function runAudit() { + const backendDirectory = path.resolve('../..', 'backend/api'); + const manifest = JSON.parse(execFileSync('go', ['run', './cmd/resource-contract'], { cwd: backendDirectory, encoding: 'utf8' })); + let responseShapeVerified = true; + try { execFileSync('go', ['test', '-count=1', './internal/logic/platform', '-run', '^TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID$'], { cwd: backendDirectory, stdio: 'pipe' }); } catch { responseShapeVerified = false; } + const viewSources = sourceFiles('src/views', ['.vue']); + const failures = auditPlatform({ + manifest, + resources: loadResources(), + readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'), + routeSources: [...sourceFiles('src/router/routes/modules', ['.ts']).values()], + viewSources, + apiSources: sourceFiles('src/api', ['.ts']), + responseShapeVerified, + }); + for (const failure of failures) console.log(failure); + if (failures.length) process.exitCode = 1; +} + +if (import.meta.url === `file://${process.argv[1]?.replaceAll('\\', '/')}`) runAudit(); diff --git a/frontend/platform_admin/scripts/audit-check.test.mjs b/frontend/platform_admin/scripts/audit-check.test.mjs new file mode 100644 index 0000000..2f7df0c --- /dev/null +++ b/frontend/platform_admin/scripts/audit-check.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { auditPlatform, scanInternalIdLeaks } from './audit-check.mjs'; + +test('只读页面将状态变更视为违规写操作', () => { + const failures = auditPlatform({ + manifest: { resources: [], routes: [] }, + resources: [], + readOnlyPage: '', + routeSources: [], + viewSources: new Map(), + apiSources: new Map(), + }); + + assert.deepEqual(failures, ['readonly: mutation action exposed (updateStatus)']); +}); + +test('扫描 API 和页面中用于展示或请求的内部 ID', () => { + const failures = scanInternalIdLeaks(new Map([ + ['src/api/leak.ts', "resourceApi.create('/gas/gas_basic', { gas_basic_id: 7 })"], + ['src/views/leak.vue', ''], + ])); + + assert.deepEqual(failures, [ + 'src/api/leak.ts: internal identifier gas_basic_id', + 'src/views/leak.vue: internal identifier id', + ]); +}); + +test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => { + const failures = auditPlatform({ + manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [ + { method: 'GET', path: '/gas/gas_basic' }, + { method: 'POST', path: '/gas/gas_basic' }, + { method: 'GET', path: '/gas/gas_basic/:identity' }, + { method: 'PUT', path: '/gas/gas_basic/:identity' }, + { method: 'PATCH', path: '/gas/gas_basic/:identity/status' }, + { method: 'DELETE', path: '/gas/gas_basic/:identity' }, + ] }, + resources: [{ name: 'gas_basic', resource: '/gas/gas_basic', mode: 'writable', pageKind: 'list', title: '气站管理', fields: [{ key: 'name', label: '名称' }] }], + readOnlyPage: '', + routeSources: ["{ component: () => import('@/views/gas/gas_basic/ListPage.vue') }"], + viewSources: new Map([['src/views/gas/gas_basic/ListPage.vue', "getResource('/gas/gas_basic')"]]), + apiSources: new Map(), + }); + + assert.deepEqual(failures, ['gas/gas_basic: missing menu route']); +}); From 1a49b07f10afaeebeacde0dc7251a141c14a9bf5 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 11:13:55 +0800 Subject: [PATCH 12/17] fix: harden platform audit coverage --- .../internal/logic/platform/resource_test.go | 20 ++++++++ .../logic/platform/task4_resources.go | 16 +++++++ backend/api/internal/routers/platform.go | 1 + backend/api/internal/routers/platform_test.go | 14 +++++- docs/平台总后台审计报告-2026-07-27.md | 3 +- .../platform_admin/scripts/audit-check.mjs | 38 ++++++++++----- .../scripts/audit-check.test.mjs | 46 +++++++++++++++++++ 7 files changed, 125 insertions(+), 13 deletions(-) diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index f970de7..55b7162 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -276,6 +276,26 @@ func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { } } +func TestListSafetyEventDisposalsReturnsOnlyTheRequestedEventHistory(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "saf_event_disposal" WHERE saf_event_identity = $1`)). + WithArgs("event-a"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event_disposal" WHERE saf_event_identity = $1 ORDER BY created_at asc LIMIT $2`)). + WithArgs("event-a", 20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "saf_event_identity", "action", "reason", "operator_identity"}). + AddRow(uint64(9), "disposal-a", nil, nil, "enabled", 1, "event-a", "close", "resolved", "operator-a")) + + ctx, recorder := updateContext(http.MethodGet, "/safety/saf_event/event-a/disposals", "event-a", nil) + ListSafetyEventDisposals(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"saf_event_identity":"event-a"`) || strings.Contains(recorder.Body.String(), `"id":`) { + t.Fatalf("disposal history did not keep the event identity-only shape: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + func TestListGasAccountProjectsGasBasicIdentityAndNeverReturnsRelationID(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`)). diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index e90c3de..b7582f3 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -423,6 +423,22 @@ func stripInternalIDs(value any) any { // DisposeSafetyEvent atomically updates an event and appends its operator-owned // action record. Disposal records deliberately have no update or delete route. +func ListSafetyEventDisposals(ctx *gin.Context) { + page, size := pageSize(ctx) + var list []models.SafEventDisposal + query := impl.DBService.Model(&models.SafEventDisposal{}).Where("saf_event_identity = ?", ctx.Param("identity")) + var total int64 + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("created_at asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": resourceResponse(list)}) +} + func DisposeSafetyEvent(ctx *gin.Context) { claims, err := middleware.ParseAuth(ctx) if err != nil { diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index fb87267..b6ad739 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -64,6 +64,7 @@ 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{"result", "evidence_uri"}, requiredRelation("user_account_identity", "user_account_id", &models.UserAccount{}), requiredRelation("staff_account_identity", "staff_account_id", &models.StaffAccount{})) + group.GET("/safety/saf_event/:identity/disposals", platform.ListSafetyEventDisposals) group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent) } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index e8f4d3d..f2bfb50 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -24,8 +24,11 @@ func TestEveryContractHasRegisteredRoute(t *testing.T) { case platform.ReadOnly: assertRouteMethods(t, routes, path, http.MethodGet) assertRouteMethods(t, routes, path+"/:identity", http.MethodGet) + assertNoRouteMethods(t, routes, path, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) + assertNoRouteMethods(t, routes, path+"/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete) case platform.AppendOnly: - assertRouteMethods(t, routes, path, http.MethodPost) + assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) + assertNoRouteMethods(t, routes, path, http.MethodPut, http.MethodPatch, http.MethodDelete) default: assertRouteMethods(t, routes, path, http.MethodGet, http.MethodPost) assertRouteMethods(t, routes, path+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) @@ -169,3 +172,12 @@ func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path st } } } + +func assertNoRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) { + t.Helper() + for _, method := range methods { + if routes[path][method] { + t.Errorf("route %s %s must not be registered", method, path) + } + } +} diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md index df70661..5a6180a 100644 --- a/docs/平台总后台审计报告-2026-07-27.md +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -12,6 +12,7 @@ | 前端资源与后端路由只按资源名和文件名进行粗略匹配 | `ResourceContract.Path` 成为唯一后端路径声明;审计逐项校验 GET/POST/PUT/PATCH/DELETE(按资源模式)以及资源路径一致性 | Go 的 `TestEveryContractHasRegisteredRoute` 和 `pnpm audit:platform` | | 页面存在但未被菜单路由实际加载时可能漏检 | 审计读取全部路由模块,解析页面动态导入,再确认页面通过 `getResource(契约路径)` 使用对应资源且路由具有 `menu.platform.*` 元数据 | `audit-check.test.mjs` 的菜单映射用例 | | API 或页面可能展示、提交数据库自增 `id` / `*_id` | 对 `src/api` 与 `src/views` 全量扫描;详情页的显式过滤逻辑被识别为防护而非泄漏 | `pnpm audit:platform` | +| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `saf_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST,且按事件标识查询处置历史;只读资源扫描 API、路由和页面中的 `updateStatus` | 6 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 | ## 身份字段与保留理由 @@ -47,7 +48,7 @@ pnpm build | `go test ./...` | 0 | 通过 | | `go build ./cmd/main` | 0 | 通过 | | `go test ./internal/routers -run '^TestEveryContractHasRegisteredRoute$' -v` | 0 | 通过 | -| `node --test scripts/audit-check.test.mjs` | 0 | 通过,3 个审计用例 | +| `node --test scripts/audit-check.test.mjs` | 0 | 通过,6 个审计用例 | | `pnpm type:check` | 0 | 通过 | | `pnpm audit:platform` | 0 | 通过 | | `pnpm build` | 0 | 通过 | diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index c72ec8e..6ea3de1 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -9,7 +9,7 @@ const sourceFiles = (dir, extensions = ['.ts', '.vue']) => new Map(files(dir).fi const mutationActions = (source) => [...source.matchAll(/resourceApi\.(create|update|updateStatus|archive)\b/g)].map((match) => match[1]); function requiredBackendRoutes(contract) { - if (contract.mode === 'append_only') return [{ method: 'POST', path: contract.path }]; + if (contract.mode === 'append_only') return [{ method: 'GET', path: contract.path }, { method: 'POST', path: contract.path }]; const resource = contract.path; const detail = `${resource}/:identity`; if (contract.mode === 'readonly') return [{ method: 'GET', path: resource }, { method: 'GET', path: detail }]; @@ -23,7 +23,7 @@ function routeCoverage(contract, routeSources, viewSources) { const expectedView = new RegExp(`getResource\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]\\s*\\)`); let hasPage = false; let hasMenu = false; - for (const source of routeSources) { + for (const [, source] of sourceEntries(routeSources, 'src/router')) { for (const match of source.matchAll(/component:\s*\(\)\s*=>\s*import\(['\"]@\/views\/([^'\"]+)['\"]\)([\s\S]{0,260}?meta:\s*\{[^}]*\})?/g)) { const view = viewSources.get(`src/views/${match[1]}`); if (!view || !expectedView.test(view)) continue; @@ -34,6 +34,11 @@ function routeCoverage(contract, routeSources, viewSources) { return { hasPage, hasMenu }; } +function sourceEntries(sources, fallbackDirectory) { + if (sources instanceof Map) return [...sources]; + return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]); +} + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -43,11 +48,11 @@ export function scanInternalIdLeaks(sources) { const failures = []; for (const [file, source] of sources) { for (const line of source.split(/\r?\n/)) { - // These are defensive filters that explicitly remove IDs from details, not leaks. - if (line.includes("key !== 'id'") || line.includes("key.endsWith('_id')")) continue; - const relation = line.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/); + // Remove only the defensive predicates, then keep scanning the rest of the line. + const scanned = line.replace(/key\s*!==\s*['\"]id['\"]/g, '').replace(/key\.endsWith\(\s*['\"]_id['\"]\s*\)/g, ''); + const relation = scanned.match(/\b([A-Za-z][A-Za-z0-9_]*_id)\b/); if (relation) failures.push(`${file}: internal identifier ${relation[1]}`); - else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(line)) failures.push(`${file}: internal identifier id`); + else if (/\bdata-index\s*=\s*['\"]id['\"]|\.id\b|[,{]\s*id\s*:/.test(scanned)) failures.push(`${file}: internal identifier id`); } } return failures; @@ -68,10 +73,21 @@ export function auditPlatform({ manifest, resources, readOnlyPage, routeSources, } if (!/^[\u4e00-\u9fff]/.test(resource.title) || resource.fields.length === 0 || resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')) || resource.fields.some((field) => !/^[\u4e00-\u9fff]/.test(field.label))) failures.push(`${label}: invalid frontend allowlist`); for (const expected of requiredBackendRoutes(contract)) if (!manifest.routes.some((route) => route.method === expected.method && route.path === expected.path)) failures.push(`${label}: missing backend ${expected.method}`); - if (contract.mode === 'append_only') continue; - const coverage = routeCoverage(contract, routeSources, viewSources); - if (!coverage.hasPage) failures.push(`${label}: missing page`); - if (!coverage.hasMenu) failures.push(`${label}: missing menu route`); + if (contract.mode === 'append_only') { + const event = resources.find((item) => item.name === 'saf_event'); + if (!event?.detailActions?.some((action) => action.name === contract.name && action.resource === contract.path)) failures.push(`${label}: missing saf_event detail action`); + if ([...viewSources.keys()].some((file) => file.includes(`/${contract.name}/`))) failures.push(`${label}: independent page exposed`); + } else { + const coverage = routeCoverage(contract, routeSources, viewSources); + if (!coverage.hasPage) failures.push(`${label}: missing page`); + if (!coverage.hasMenu) failures.push(`${label}: missing menu route`); + } + if (contract.mode === 'readonly') { + const statusCall = new RegExp(`resourceApi\\.updateStatus\\(\\s*['\"]${escapeRegExp(contract.path)}['\"]`); + for (const [file, source] of [...sourceEntries(routeSources, 'src/router'), ...sourceEntries(apiSources, 'src/api'), ...sourceEntries(viewSources, 'src/views')]) { + if (statusCall.test(source)) failures.push(`${label}: readonly status mutation in ${file}`); + } + } } if (JSON.stringify(resources.map((item) => item.name).sort()) !== JSON.stringify(manifest.resources.map((item) => item.name).sort())) failures.push('catalogue names differ from backend ExpectedResources'); const readonlyMutations = mutationActions(readOnlyPage); @@ -97,7 +113,7 @@ function runAudit() { manifest, resources: loadResources(), readOnlyPage: fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'), - routeSources: [...sourceFiles('src/router/routes/modules', ['.ts']).values()], + routeSources: sourceFiles('src/router', ['.ts']), viewSources, apiSources: sourceFiles('src/api', ['.ts']), responseShapeVerified, diff --git a/frontend/platform_admin/scripts/audit-check.test.mjs b/frontend/platform_admin/scripts/audit-check.test.mjs index 2f7df0c..6cdfdb2 100644 --- a/frontend/platform_admin/scripts/audit-check.test.mjs +++ b/frontend/platform_admin/scripts/audit-check.test.mjs @@ -27,6 +27,18 @@ test('扫描 API 和页面中用于展示或请求的内部 ID', () => { ]); }); +test('防护表达式不能掩盖同一行的内部 ID 泄漏', () => { + const failures = scanInternalIdLeaks(new Map([ + ['src/views/leak.vue', "const visible = row.id; const safe = key !== 'id';"], + ['src/api/leak.ts', "send({ gas_basic_id: 7 }); const safe = key.endsWith('_id');"], + ])); + + assert.deepEqual(failures, [ + 'src/views/leak.vue: internal identifier id', + 'src/api/leak.ts: internal identifier gas_basic_id', + ]); +}); + test('每个资源必须由带菜单元数据的路由实际加载对应页面', () => { const failures = auditPlatform({ manifest: { resources: [{ domain: 'gas', name: 'gas_basic', path: '/gas/gas_basic', mode: 'writable', pageKind: 'list' }], routes: [ @@ -46,3 +58,37 @@ test('每个资源必须由带菜单元数据的路由实际加载对应页面', assert.deepEqual(failures, ['gas/gas_basic: missing menu route']); }); + +test('仅追加处置必须挂在安全事件详情动作且不得有独立页面', () => { + const failures = auditPlatform({ + manifest: { resources: [{ domain: 'safety', name: 'saf_event_disposal', path: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list' }], routes: [ + { method: 'GET', path: '/safety/saf_event/:identity/disposals' }, + { method: 'POST', path: '/safety/saf_event/:identity/disposals' }, + ] }, + resources: [{ name: 'saf_event_disposal', resource: '/safety/saf_event/:identity/disposals', mode: 'append_only', pageKind: 'list', title: '事件处置', fields: [{ key: 'action', label: '处置动作' }] }], + readOnlyPage: '', + routeSources: [], + viewSources: new Map([['src/views/safety/saf_event_disposal/ListPage.vue', '