From 7ebe25fe34110be90335f78e8b1b1d93b110a66a Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 10:25:04 +0800 Subject: [PATCH] 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 @@ - +