From e7303430938cf01d9b9b30769a6eced9fe965260 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Mon, 27 Jul 2026 01:11:38 +0800 Subject: [PATCH] feat: add platform resource audit baseline --- .../api/internal/logic/platform/resource.go | 69 +++++++++++++++++++ .../internal/logic/platform/resource_test.go | 19 +++++ backend/api/internal/routers/platform_test.go | 22 ++++++ frontend/platform_admin/package.json | 1 + .../platform_admin/scripts/audit-check.mjs | 39 +++++++++++ 5 files changed, 150 insertions(+) create mode 100644 backend/api/internal/logic/platform/resource.go create mode 100644 backend/api/internal/logic/platform/resource_test.go create mode 100644 backend/api/internal/routers/platform_test.go diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go new file mode 100644 index 0000000..c81e3d6 --- /dev/null +++ b/backend/api/internal/logic/platform/resource.go @@ -0,0 +1,69 @@ +package platform + +// ResourceMode describes the operations a platform-admin resource supports. +type ResourceMode string + +const ( + Writable ResourceMode = "writable" + ReadOnly ResourceMode = "readonly" + AppendOnly ResourceMode = "append_only" +) + +// ResourceContract is the expected cross-layer representation of one resource. +type ResourceContract struct { + Domain string + Name string + PageKind string + Mode ResourceMode +} + +// 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: "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: "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: "device", 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 new file mode 100644 index 0000000..8241f79 --- /dev/null +++ b/backend/api/internal/logic/platform/resource_test.go @@ -0,0 +1,19 @@ +package platform + +import "testing" + +func TestExpectedResources(t *testing.T) { + assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") + assertContract(t, ExpectedResources(), "device", "saf_event", Writable, "list") + assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") +} + +func assertContract(t *testing.T, contracts []ResourceContract, domain, name string, mode ResourceMode, pageKind string) { + t.Helper() + for _, contract := range contracts { + if contract.Domain == domain && contract.Name == name && contract.Mode == mode && contract.PageKind == pageKind { + return + } + } + t.Fatalf("missing resource contract %s/%s with mode %q and page kind %q", domain, name, mode, pageKind) +} diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go new file mode 100644 index 0000000..8337092 --- /dev/null +++ b/backend/api/internal/routers/platform_test.go @@ -0,0 +1,22 @@ +package routers + +import ( + "testing" + + "github.com/gin-gonic/gin" +) + +func TestPlatformGasRouteUsesGasBasic(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + for _, route := range engine.Routes() { + if route.Path == "/heqi/platform/v1/gas/gas_station" { + t.Fatal("gas station route must use gas_basic as its resource name") + } + if route.Method == "GET" && route.Path == "/heqi/platform/v1/gas/gas_basic" { + return + } + } + t.Fatal("gas_basic list route is not registered") +} diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 73544c1..afd6f91 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -11,6 +11,7 @@ "report": "vite build --config ./config/vite.config.ts --mode report", "preview": "pnpm run build && vite preview --host", "type:check": "vue-tsc -p tsconfig.build.json --noEmit --skipLibCheck", + "audit:platform": "node scripts/audit-check.mjs", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write ." diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 3a363ff..4050e7d 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,6 +1,38 @@ 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'], +]; + +function fileIncludes(file, value) { + return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(value); +} + +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`); + } + return missing; +} + function walk(dir, acc = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); @@ -49,3 +81,10 @@ console.log(JSON.stringify({ 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; +}