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..f240018 --- /dev/null +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-5-report.md @@ -0,0 +1,37 @@ +# 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`). + +## 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/.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..6a7f44f --- /dev/null +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -0,0 +1,45 @@ +# 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. + +## 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. + +## 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. + +## 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/cmd/resource-contract/main.go b/backend/api/cmd/resource-contract/main.go new file mode 100644 index 0000000..8c07334 --- /dev/null +++ b/backend/api/cmd/resource-contract/main.go @@ -0,0 +1,45 @@ +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"` + Path string `json:"path"` + 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, 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/initdb/platform.go b/backend/api/internal/initdb/platform.go index d987e8e..28c3042 100644 --- a/backend/api/internal/initdb/platform.go +++ b/backend/api/internal/initdb/platform.go @@ -37,11 +37,17 @@ func InitPlatformAccess(database *gorm.DB) error { {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "delivery", Name: "配送管理", Icon: "icon-car", Path: "/delivery/basic", SortNo: 30}, {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "staff", Name: "服务人员", Icon: "icon-user", Path: "/staff/list", SortNo: 40}, {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "user", Name: "业主客户", Icon: "icon-user-group", Path: "/user/list", SortNo: 50}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 60}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 70}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 80}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 90}, - {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 100}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "device", Name: "设备管理", Icon: "icon-storage", Path: "/device", SortNo: 60}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "safety", Name: "安全治理", Icon: "icon-safe", Path: "/safety", SortNo: 70}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "ec", Name: "电商管理", Icon: "icon-shopping", Path: "/ec/product", SortNo: 80}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "finance", Name: "财务管理", Icon: "icon-safe", Path: "/finance/payment", SortNo: 90}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "wallet", Name: "钱包中心", Icon: "icon-wallet", Path: "/wallet/list", SortNo: 100}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "report", Name: "统计报表", Icon: "icon-bar-chart", Path: "/report/list", SortNo: 110}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "content", Name: "内容管理", Icon: "icon-file", Path: "/content", SortNo: 120}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "notification", Name: "通知管理", Icon: "icon-notification", Path: "/notification", SortNo: 130}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "customer_service", Name: "客户服务", Icon: "icon-customer-service", Path: "/customer_service", SortNo: 140}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "audit", Name: "审计合规", Icon: "icon-history", Path: "/audit", SortNo: 150}, + {Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled", Version: 1}, MenuCode: "platform", Name: "平台配置", Icon: "icon-settings", Path: "/platform/account", SortNo: 160}, } for index := range menus { menu := menus[index] diff --git a/backend/api/internal/initdb/platform_test.go b/backend/api/internal/initdb/platform_test.go new file mode 100644 index 0000000..9b6ad07 --- /dev/null +++ b/backend/api/internal/initdb/platform_test.go @@ -0,0 +1,51 @@ +package initdb + +import ( + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestInitPlatformAccessSeedsEveryProtectedFrontendDomain(t *testing.T) { + sqlDatabase, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDatabase.Close() }) + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 ORDER BY "platform_role"."id" LIMIT $2`)). + WithArgs("root", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "version", "role_code", "name", "data_scope", "is_system"}). + AddRow(uint64(1), "root-role", "enabled", 1, "root", "Root", "global", true)) + + domains := []string{ + "dashboard", "gas", "delivery", "staff", "user", "device", "safety", + "ec", "finance", "wallet", "report", "content", "notification", + "customer_service", "audit", "platform", + } + for index, domain := range domains { + menuID := uint64(index + 10) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" WHERE menu_code = $1 ORDER BY "platform_menu"."id" LIMIT $2`)). + WithArgs(domain, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(menuID, domain+"-menu", "enabled", 1, uint64(0), domain, domain, "", "/"+domain, index)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role_menu_relation" WHERE platform_role_id = $1 AND platform_menu_id = $2 ORDER BY "platform_role_menu_relation"."id" LIMIT $3`)). + WithArgs(uint64(1), menuID, 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "platform_role_id", "platform_menu_id"}). + AddRow(uint64(index+100), uint64(1), menuID)) + } + + if err := InitPlatformAccess(database); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/backend/api/internal/logic/platform/access.go b/backend/api/internal/logic/platform/access.go new file mode 100644 index 0000000..3402273 --- /dev/null +++ b/backend/api/internal/logic/platform/access.go @@ -0,0 +1,91 @@ +package platform + +import ( + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" +) + +const platformMenusContextKey = "platform_authorized_menus" + +func loadPlatformMenus(roleCode string) ([]models.PlatformMenu, error) { + var menus []models.PlatformMenu + if roleCode == "root" { + err := impl.DBService.Order("sort_no asc, id asc").Find(&menus).Error + return menus, err + } + + var role models.PlatformRole + if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, "enabled").First(&role).Error; err != nil { + return nil, err + } + err := impl.DBService. + Select("platform_menu.*"). + Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id"). + Where("platform_role_menu_relation.platform_role_id = ? AND platform_menu.status = ?", role.ID, "enabled"). + Order("sort_no asc, id asc"). + Find(&menus).Error + return menus, err +} + +func platformMenuAllowsPath(menus []models.PlatformMenu, requestPath string) bool { + marker := "/platform/v1/" + index := strings.Index(requestPath, marker) + if index < 0 { + return false + } + relative := strings.Trim(requestPath[index+len(marker):], "/") + domain := strings.Split(relative, "/")[0] + for _, menu := range menus { + if menu.MenuCode == domain { + return true + } + menuPath := strings.Trim(menu.Path, "/") + if menuPath != "" && strings.Split(menuPath, "/")[0] == domain { + return true + } + } + return false +} + +// RequirePlatformMenuAccess enforces role-menu authorization after JWT authentication. +func RequirePlatformMenuAccess() gin.HandlerFunc { + return func(ctx *gin.Context) { + if strings.Contains(ctx.Request.URL.Path, "/platform/v1/auth/") { + ctx.Next() + return + } + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + ctx.Abort() + return + } + if claims.Role == "root" { + ctx.Next() + return + } + menus, err := loadPlatformMenus(claims.Role) + if err != nil || !platformMenuAllowsPath(menus, ctx.Request.URL.Path) { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + ctx.Abort() + return + } + ctx.Set(platformMenusContextKey, menus) + ctx.Next() + } +} + +func requirePlatformRoot(ctx *gin.Context) bool { + claims, err := middleware.ParseAuth(ctx) + if err == nil && claims.Role == "root" { + return true + } + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return false +} diff --git a/backend/api/internal/logic/platform/account.go b/backend/api/internal/logic/platform/account.go index f97aaff..4d055a4 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,12 +49,12 @@ 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 } - infra.Response.Success(ctx, account) + respondCreatedResource(ctx, account) } func UpdateGasAccount(ctx *gin.Context) { @@ -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,12 +90,12 @@ 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 } - infra.Response.Success(ctx, account) + respondCreatedResource(ctx, account) } func UpdateDeliveryAccount(ctx *gin.Context) { @@ -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/audit.go b/backend/api/internal/logic/platform/audit.go new file mode 100644 index 0000000..1918ab8 --- /dev/null +++ b/backend/api/internal/logic/platform/audit.go @@ -0,0 +1,103 @@ +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" +) + +var errApprovalNotProcessable = errors.New("approval is not processable") + +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 + } + 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 + err = impl.DBService.Transaction(func(transaction *gorm.DB) error { + 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, + }) + if err != nil { + return err + } + 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 errApprovalNotProcessable + } + 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, errApprovalNotProcessable) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + 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..024d5cd --- /dev/null +++ b/backend/api/internal/logic/platform/audit_test.go @@ -0,0 +1,135 @@ +package platform + +import ( + "database/sql/driver" + "net/http" + "regexp" + "strings" + "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) { + 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 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"`)). + 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()) + } +} + +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 { + actual, ok := value.(string) + return ok && strings.Contains(actual, string(expected)) +} diff --git a/backend/api/internal/logic/platform/auth.go b/backend/api/internal/logic/platform/auth.go index 3932c2f..36e6b16 100644 --- a/backend/api/internal/logic/platform/auth.go +++ b/backend/api/internal/logic/platform/auth.go @@ -90,9 +90,28 @@ func CurrentProfile(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrRecordNotFound) return } + menus, err := loadPlatformMenus(account.PlatformRoleCode) + if err != nil { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } + menuCodes := make([]string, 0, len(menus)) + seenMenuCodes := make(map[string]bool, len(menus)) + for _, menu := range menus { + codes := []string{menu.MenuCode} + if path := strings.Trim(menu.Path, "/"); path != "" { + codes = append(codes, strings.Split(path, "/")[0]) + } + for _, code := range codes { + if code != "" && !seenMenuCodes[code] { + menuCodes = append(menuCodes, code) + seenMenuCodes[code] = true + } + } + } infra.Response.Success(ctx, gin.H{ "identity": account.Identity, "username": account.Username, "display_name": account.DisplayName, - "avatar": account.Avatar, "role_code": account.PlatformRoleCode, + "avatar": account.Avatar, "role_code": account.PlatformRoleCode, "menu_codes": menuCodes, }) } diff --git a/backend/api/internal/logic/platform/delivery.go b/backend/api/internal/logic/platform/delivery.go index 7409331..0d3f545 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) + respondCreatedResource(ctx, 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/gas.go b/backend/api/internal/logic/platform/gas.go index 606cf5d..ee1e594 100644 --- a/backend/api/internal/logic/platform/gas.go +++ b/backend/api/internal/logic/platform/gas.go @@ -26,7 +26,7 @@ func CreateGasBasic(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, request) + respondCreatedResource(ctx, request) } // UpdateGasBasic 更新可燃气体站基础资料。 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/logic/platform/platform.go b/backend/api/internal/logic/platform/platform.go index fe2be58..b66084e 100644 --- a/backend/api/internal/logic/platform/platform.go +++ b/backend/api/internal/logic/platform/platform.go @@ -3,6 +3,8 @@ package platform import ( "errors" + "reflect" + "strings" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -38,7 +40,8 @@ func listPage[T any](ctx *gin.Context) { page, size := pageSize(ctx) var list []T var total int64 - databaseQuery := impl.DBService.Model(new(T)) + model := new(T) + databaseQuery := applyKeywordFilter(ctx, impl.DBService.Model(model), model) if err := databaseQuery.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -47,7 +50,83 @@ func listPage[T any](ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": total, "list": list}) + response, err := publicResourceResponse(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) +} + +var keywordSafeColumns = map[string]bool{ + "code": true, "name": true, "username": true, "display_name": true, + "role_code": true, "delivery_code": true, "work_status": true, + "credential_type": true, "device_no": true, "model": true, + "online_status": true, "rule_code": true, "action": true, + "event_code": true, "title": true, "result": true, + "product_code": true, "value": true, "order_no": true, + "channel": true, "settlement_no": true, "subject_type": true, + "content_type": true, "publish_status": true, "template_code": true, + "ticket_no": true, "category": true, "priority": true, + "platform_role_code": true, "data_scope": true, "menu_code": true, + "path": true, "report_code": true, "report_type": true, + "stat_period": true, "dimension": true, "metric_code": true, + "scope_type": true, "business_type": true, "resource_type": true, +} + +func applyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB { + keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword"))) + if keyword == "" { + return query + } + columns := keywordColumns(model) + if len(columns) == 0 { + return query + } + conditions := make([]string, 0, len(columns)) + arguments := make([]any, 0, len(columns)) + for _, column := range columns { + conditions = append(conditions, `LOWER("`+column+`") LIKE ?`) + arguments = append(arguments, "%"+keyword+"%") + } + return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...) +} + +func keywordColumns(model any) []string { + modelType := reflect.TypeOf(model) + for modelType.Kind() == reflect.Pointer { + modelType = modelType.Elem() + } + columns := make([]string, 0) + for index := 0; index < modelType.NumField(); index++ { + field := modelType.Field(index) + if field.Anonymous || field.Type.Kind() != reflect.String || strings.Contains(field.Tag.Get("gorm"), "type:jsonb") { + continue + } + column := gormColumn(field.Tag.Get("gorm")) + if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) { + columns = append(columns, column) + } + } + return columns +} + +func isSensitiveKeywordColumn(model any, column string) bool { + switch model.(type) { + case *models.UserAccount, *models.StaffAccount: + return column == "name" + default: + return false + } +} + +func gormColumn(tag string) string { + for _, part := range strings.Split(tag, ";") { + if strings.HasPrefix(part, "column:") { + return strings.TrimPrefix(part, "column:") + } + } + return "" } func getByIdentity[T any](ctx *gin.Context) { @@ -56,7 +135,12 @@ func getByIdentity[T any](ctx *gin.Context) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, data) + response, err := publicResourceResponse(data) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, protectPreciseLocation(ctx, new(T), response)) } func updateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) { diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index ebe1489..9e1cb88 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,50 +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: "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"}, + 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", ReadOnly, "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/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index e2850df..05e7146 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -6,10 +6,14 @@ import ( "errors" "net/http" "net/http/httptest" + "reflect" "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,8 +25,10 @@ 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(), "ec", "ec_order_item", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") + assertContract(t, ExpectedResources(), "delivery", "delivery_track_point", ReadOnly, "list") } func TestResourceDefinitionAllowsOnlySupportedMethods(t *testing.T) { @@ -151,6 +157,7 @@ func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t * mock.ExpectCommit() ctx, recorder := updateContext(test.method, "/roles/role-a", "role-a", []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) test.handler(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrRecordNotFound))) @@ -159,6 +166,662 @@ 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))) + 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"`) || strings.Contains(recorder.Body.String(), `_id"`) { + t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) + } + 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, &models.EcOrderItem{}, []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, &models.EcOrderItem{}, []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 TestPrepareResourceValuesNormalizesStringJSONBFields(t *testing.T) { + t.Run("safety rule", func(t *testing.T) { + ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":{"max":10},"action":"close-valve","gray_scope":["north"]}`)) + values, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action", "gray_scope"}, nil) + if err != nil { + t.Fatal(err) + } + if values["threshold"] != `{"max":10}` || values["gray_scope"] != `["north"]` { + t.Fatalf("jsonb values were not normalized to strings: %#v", values) + } + }) + + t.Run("order item", func(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","product_snapshot":{"name":"液化气"},"quantity":2,"sale_amount":500}`)) + values, err := prepareResourceValues(ctx, &models.EcOrderItem{}, []string{"product_snapshot", "quantity", "sale_amount"}, []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["product_snapshot"] != `{"name":"液化气"}` { + t.Fatalf("product snapshot was not normalized to a string: %#v", values) + } + assertMockExpectations(t, mock) + }) + + t.Run("invalid json string", func(t *testing.T) { + ctx, _ := updateContext(http.MethodPost, "/safety/saf_rule", "", []byte(`{"rule_code":"pressure-limit","threshold":"{invalid}","action":"close-valve"}`)) + if _, err := prepareResourceValues(ctx, &models.SafRule{}, []string{"rule_code", "threshold", "action"}, nil); err == nil { + t.Fatal("invalid JSON string was accepted for a string/jsonb field") + } + }) +} + +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() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id","identity" FROM "gas_basic" WHERE id IN ($1)`)). + WithArgs(uint64(8)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(8), "gas-a")) + + 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) + body := recorder.Body.String() + if !strings.Contains(body, `"identity"`) || !strings.Contains(body, `"gas_basic_identity":"gas-a"`) { + t.Fatalf("create response omitted public identities: %s", body) + } + if strings.Contains(body, `"gas_basic_id"`) || strings.Contains(body, `"password_hash"`) { + t.Fatalf("create response exposed internal or sensitive fields: %s", body) + } + assertMockExpectations(t, mock) +} + +func TestListGasAccountAppliesKeywordToCountAndRows(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + keywordWhere := ` WHERE (LOWER("username") LIKE $1 OR LOWER("display_name") LIKE $2 OR LOWER("role_code") LIKE $3)` + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "gas_account"`+keywordWhere)). + WithArgs("%operator%", "%operator%", "%operator%"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "gas_account"`+keywordWhere+` ORDER BY created_at desc LIMIT $4`)). + WithArgs("%operator%", "%operator%", "%operator%", 20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "gas_basic_id", "username", "display_name", "password_hash", "role_code"})) + + ctx, recorder := updateContext(http.MethodGet, "/gas/gas_account?keyword=Operator", "", nil) + ListGasAccount(ctx) + + assertResponseCode(t, recorder, 0) + assertMockExpectations(t, mock) +} + +func TestKeywordColumnsUseSafeTextAllowlist(t *testing.T) { + tests := []struct { + name string + model any + want []string + }{ + {"safety rule excludes jsonb", &models.SafRule{}, []string{"rule_code", "action"}}, + {"gas basic excludes sensitive fields", &models.GasBasic{}, []string{"code", "name"}}, + {"user address has no searchable safe text", &models.UserAddress{}, []string{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := keywordColumns(test.model) + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("keywordColumns(%T) = %#v, want %#v", test.model, got, test.want) + } + }) + } +} + +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) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) + 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 TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_role" WHERE role_code = $1 AND status = $2 ORDER BY "platform_role"."id" LIMIT $3`)). + WithArgs("finance_operator", "enabled", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "role_code", "name", "data_scope", "is_system"}). + AddRow(uint64(7), "finance-role", nil, nil, "enabled", 1, "finance_operator", "Finance", "global", false)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT platform_menu.* FROM "platform_menu" JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id WHERE platform_role_menu_relation.platform_role_id = $1 AND platform_menu.status = $2 ORDER BY sort_no asc, id asc`)). + WithArgs(uint64(7), "enabled"). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(uint64(9), "finance-menu", nil, nil, "enabled", 1, uint64(0), "finance", "Finance", "", "/finance/payment", 1)) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) + ctx.Set("Auth", &types.JwtClaims{Role: "finance_operator"}) + ListPlatformMenu(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"identity":"finance-menu"`) || strings.Contains(body, `"gas-menu"`) { + t.Fatalf("non-root menu response was not constrained: %s", body) + } + 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) + 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 TestCreatedResourceResponseUsesSafeAllowlist(t *testing.T) { + response := maskCreatedSensitiveFields(map[string]any{ + "identity": "address-a", + "user_account_identity": "user-a", + "status": "draft", + "version": float64(1), + "phone": "13800138000", + "real_name": "张三", + "credential_no": "CERT-123456", + "address": "敏感详细地址", + "principal": "负责人", + "credit_code": "CREDIT-123", + "longitude": "120.123456", + "latitude": "30.456789", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"identity":"address-a"`) || !strings.Contains(body, `"user_account_identity":"user-a"`) || !strings.Contains(body, `"status":"draft"`) { + t.Fatalf("created response omitted safe public fields: %s", body) + } + for _, forbidden := range []string{`"phone"`, `"phone_masked"`, `"real_name"`, `"credential_no"`, `"address"`, `"principal"`, `"credit_code"`, `"longitude"`, `"latitude"`, "敏感详细地址", "负责人", "CREDIT-123", "120.123456", "30.456789"} { + if strings.Contains(body, forbidden) { + t.Fatalf("created response exposed sensitive field %s: %s", forbidden, body) + } + } +} + +func TestDefaultResourceResponseMasksPIIAndCoordinates(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/user/account/user-a", "user-a", nil) + response := protectPreciseLocation(ctx, &models.UserAccount{}, map[string]any{ + "identity": "user-a", + "name": "张三", + "phone": "13800138000", + "avatar": "https://private.example/avatar.png", + "address": "敏感详细地址", + "longitude": "120.123456", + "latitude": "30.456789", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"identity":"user-a"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) || !strings.Contains(body, `"name_masked"`) { + t.Fatalf("default response omitted safe identity or masked PII: %s", body) + } + for _, forbidden := range []string{`"phone":`, `"name":`, `"avatar":`, `"address":`, `"longitude":"120.123456"`, `"latitude":"30.456789"`, "张三", "敏感详细地址", "private.example"} { + if strings.Contains(body, forbidden) { + t.Fatalf("default response leaked %s: %s", forbidden, body) + } + } +} + +func TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/user/address/address-a", "address-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + response := protectPreciseLocation(ctx, &models.UserAddress{}, map[string]any{ + "identity": "address-a", + "address": "敏感详细地址", + "longitude": "120.123456", + "latitude": "30.456789", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"longitude":"120.123456"`) || !strings.Contains(body, `"latitude":"30.456789"`) { + t.Fatalf("authorized response omitted precise coordinates: %s", body) + } + if strings.Contains(body, `"address":`) || strings.Contains(body, "敏感详细地址") { + t.Fatalf("precise location scope leaked address PII: %s", body) + } +} + +func TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets(t *testing.T) { + ctx, _ := updateContext(http.MethodGet, "/staff/credential/credential-a", "credential-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + response := protectPreciseLocation(ctx, &models.StaffCredential{}, map[string]any{ + "identity": "credential-a", + "credential_type": "installer", + "credential_no": "CERT-123456", + "evidence_uri": "private://evidence", + "file_uri": "private://file", + "attachment_uri": "private://attachment", + }) + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + body := string(encoded) + if !strings.Contains(body, `"identity":"credential-a"`) || !strings.Contains(body, `"credential_type":"installer"`) { + t.Fatalf("safe credential fields were removed: %s", body) + } + for _, forbidden := range []string{"credential_no", "evidence_uri", "file_uri", "attachment_uri", "CERT-123456", "private://"} { + if strings.Contains(body, forbidden) { + t.Fatalf("credential response leaked %s: %s", forbidden, body) + } + } +} + +func TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" WHERE identity = $1 ORDER BY "platfrom_account"."id" LIMIT $2`)). + WithArgs("account-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). + AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account/account-a", "account-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + GetPlatfromAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"display_name_masked"`) || !strings.Contains(body, `"phone_masked":"138****8000"`) { + t.Fatalf("platform account detail omitted masked PII: %s", body) + } + for _, forbidden := range []string{`"display_name":`, `"avatar":`, "张三", "private://avatar"} { + if strings.Contains(body, forbidden) { + t.Fatalf("platform account detail leaked %s: %s", forbidden, body) + } + } + assertMockExpectations(t, mock) +} + +func TestPlatformAccountListMasksDisplayNameAndAvatar(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "platfrom_account"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platfrom_account" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "username", "display_name", "avatar", "password_hash", "platform_role_code", "phone"}). + AddRow(uint64(1), "account-a", nil, nil, "enabled", 1, "operator", "张三", "private://avatar", "hash", "finance_operator", "13800138000")) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platfrom_account", "", nil) + ListPlatfromAccount(ctx) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if !strings.Contains(body, `"display_name_masked"`) || strings.Contains(body, `"display_name":`) || strings.Contains(body, `"avatar":`) { + t.Fatalf("platform account list did not apply the masked projection: %s", body) + } + assertMockExpectations(t, mock) +} + +func TestNonRootCannotManagePlatformRolesOrMenus(t *testing.T) { + tests := []struct { + name string + handler gin.HandlerFunc + method string + target string + identity string + body string + }{ + {"create role", CreatePlatformRole, http.MethodPost, "/platform/platform_role", "", `{"role_code":"auditor","name":"Auditor"}`}, + {"create menu", CreatePlatformMenu, http.MethodPost, "/platform/platform_menu", "", `{"menu_code":"audit","name":"Audit","path":"/audit"}`}, + {"update menu status", UpdatePlatformMenuStatus, http.MethodPatch, "/platform/platform_menu/menu-a/status", "menu-a", `{"status":"disabled"}`}, + {"archive menu", ArchivePlatformMenu, http.MethodDelete, "/platform/platform_menu/menu-a", "menu-a", ``}, + {"replace role menus", ReplacePlatformRoleMenus, http.MethodPut, "/platform/platform_role/role-a/menu", "role-a", `{"menu_identities":[]}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(test.method, test.target, test.identity, []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) + + test.handler(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) + assertMockExpectations(t, mock) + }) + } +} + +func TestPlatformMenuAllowsOnlyAssignedDomain(t *testing.T) { + menus := []models.PlatformMenu{ + {MenuCode: "finance", Path: "/finance"}, + {MenuCode: "fin_payment", Path: "/finance/fin-payment"}, + } + if !platformMenuAllowsPath(menus, "/heqi/platform/v1/finance/fin_payment") { + t.Fatal("assigned finance domain should be allowed") + } + if platformMenuAllowsPath(menus, "/heqi/platform/v1/user/account") { + t.Fatal("unassigned user domain should be denied") + } +} + +func TestCreatePlatformAccountRequiresAssignableNonRootRole(t *testing.T) { + tests := []struct { + name string + body string + }{ + {"missing role", `{"username":"operator","password":"secure-password"}`}, + {"root role", `{"username":"operator","password":"secure-password","platform_role_code":"root"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(http.MethodPost, "/platform/platfrom_account", "", []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) + + CreatePlatfromAccount(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) + assertMockExpectations(t, mock) + }) + } +} + +func TestNonRootCannotAssignPlatformAccountRole(t *testing.T) { + tests := []struct { + name string + handler gin.HandlerFunc + method string + identity string + body string + }{ + {"create account", CreatePlatfromAccount, http.MethodPost, "", `{"username":"operator","password":"secure-password","platform_role_code":"auditor"}`}, + {"change account role", UpdatePlatfromAccount, http.MethodPut, "account-a", `{"platform_role_code":"auditor"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + ctx, recorder := updateContext(test.method, "/platform/platfrom_account/"+test.identity, test.identity, []byte(test.body)) + ctx.Set("Auth", &types.JwtClaims{Role: "platform_operator"}) + + test.handler(ctx) + + assertResponseCode(t, recorder, int32(status.Code(errcode.ErrPermissionDenied))) + assertMockExpectations(t, mock) + }) + } +} + +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"`)). + 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)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity"}).AddRow(uint64(7), "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 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() + 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")) + 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) + + 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 TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT count(*) FROM "delivery_track_point"`)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" ORDER BY created_at desc LIMIT $1`)). + WithArgs(20). + 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", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789")) + 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_point", "", nil) + listResource(ctx, &models.DeliveryTrackPoint{}) + + assertResponseCode(t, recorder, 0) + body := recorder.Body.String() + if strings.Contains(body, "120.123456") || strings.Contains(body, "30.456789") { + t.Fatalf("track-point list exposed precise coordinates without scope: %s", body) + } + if !strings.Contains(body, `"delivery_track_identity":"track-a"`) { + t.Fatalf("track-point list omitted its public relation identity: %s", body) + } + assertMockExpectations(t, mock) +} + +func TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE identity = $1 ORDER BY "delivery_track_point"."id" LIMIT $2`)). + WithArgs("point-a", 1). + 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", now, now, "enabled", 1, uint64(7), "arrival", now, "120.123456", "30.456789")) + 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_point/point-a", "point-a", nil) + ctx.Set("Auth", &types.JwtClaims{Extend: map[string]string{"location_scope": "precise"}}) + getResource(ctx, &models.DeliveryTrackPoint{}) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), "120.123456") || !strings.Contains(recorder.Body.String(), "30.456789") { + t.Fatalf("authorized track-point detail omitted 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() @@ -171,6 +834,7 @@ func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactional mock.ExpectCommit() ctx, recorder := updateContext(http.MethodPut, "/roles/role-a/menus", "role-a", []byte(`{"menu_identities":[]}`)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, 0) @@ -186,6 +850,7 @@ func TestReplacePlatformRoleMenusRejectsSystemRoleBeforeChangingRelations(t *tes mock.ExpectRollback() ctx, recorder := updateContext(http.MethodPut, "/roles/root-role/menus", "root-role", []byte(`{"menu_identities":[]}`)) + ctx.Set("Auth", &types.JwtClaims{Role: "root"}) ReplacePlatformRoleMenus(ctx) assertResponseCode(t, recorder, int32(status.Code(errcode.ErrInvalidArgument))) diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/role.go index e164edd..ba98f5e 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/role.go @@ -5,6 +5,7 @@ import ( "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" @@ -21,6 +22,9 @@ func GetPlatformRole(ctx *gin.Context) { getByIdentity[models.PlatformRole](ctx) // CreatePlatformRole 创建非内置平台角色。 func CreatePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request models.PlatformRole if err := ctx.ShouldBindJSON(&request); err != nil || request.RoleCode == "" || request.Name == "" || request.RoleCode == "root" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -35,11 +39,14 @@ func CreatePlatformRole(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, request) + respondCreatedResource(ctx, request) } // UpdatePlatformRole 更新非内置平台角色。 func UpdatePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request struct { Name string `json:"name" binding:"required,max=64"` DataScope string `json:"data_scope" binding:"required,max=32"` @@ -61,45 +68,116 @@ 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) { + if !requirePlatformRoot(ctx) { + return + } var request platformMenuRequest if err := ctx.ShouldBindJSON(&request); err != nil { 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 } - infra.Response.Success(ctx, menu) + respondCreatedResource(ctx, menu) } func UpdatePlatformMenu(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platformMenuRequest if err := ctx.ShouldBindJSON(&request); err != nil { 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"}) +} + +func UpdatePlatformMenuStatus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + UpdateRecordStatus(ctx, &models.PlatformMenu{}) +} + +func ArchivePlatformMenu(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + ArchiveRecord(ctx, &models.PlatformMenu{}) } type platformRoleMenusRequest struct { - MenuIdentities []string `json:"menu_identities" binding:"required"` + MenuIdentities []string `json:"menu_identities"` } // ReplacePlatformRoleMenus replaces every menu assignment for a role atomically. func ReplacePlatformRoleMenus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platformRoleMenusRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -147,8 +225,33 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"updated": true}) } +// ListPlatformRoleMenuIdentities returns the current assignment for the role editor. +func ListPlatformRoleMenuIdentities(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } + var role models.PlatformRole + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { + respondRecordError(ctx, err) + return + } + var identities []string + if err := impl.DBService.Model(&models.PlatformMenu{}). + Joins("JOIN platform_role_menu_relation ON platform_role_menu_relation.platform_menu_id = platform_menu.id"). + Where("platform_role_menu_relation.platform_role_id = ?", role.ID). + Order("platform_menu.sort_no asc, platform_menu.id asc"). + Pluck("platform_menu.identity", &identities).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"menu_identities": identities}) +} + // UpdatePlatformRoleStatus 更新非内置平台角色状态,系统角色始终受保护。 func UpdatePlatformRoleStatus(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request struct { Status string `json:"status" binding:"required,max=32"` } @@ -170,6 +273,9 @@ func UpdatePlatformRoleStatus(ctx *gin.Context) { // ArchivePlatformRole 归档非内置平台角色,系统角色始终受保护。 func ArchivePlatformRole(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var role models.PlatformRole if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&role).Error; err != nil { respondRecordError(ctx, err) @@ -184,25 +290,38 @@ func ArchivePlatformRole(ctx *gin.Context) { // ListPlatformMenu 返回菜单树构建所需的有序菜单列表。 func ListPlatformMenu(ctx *gin.Context) { - var list []models.PlatformMenu - if err := impl.DBService.Order("sort_no asc, id asc").Find(&list).Error; err != nil { + claims, err := middleware.ParseAuth(ctx) + if err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": len(list), "list": list}) + list, err := loadPlatformMenus(claims.Role) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": len(list), "list": platformMenuViews(list)}) } // ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。 func ListPlatfromAccount(ctx *gin.Context) { page, size := pageSize(ctx) - list, total, err := models.ListPlatfromAccount(page, size) - if err != nil { + var list []models.PlatfromAccount + var total int64 + query := applyKeywordFilter(ctx, impl.DBService.Model(&models.PlatfromAccount{}), &models.PlatfromAccount{}) + if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return } - views := make([]gin.H, 0, len(list)) + if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + views := make([]map[string]any, 0, len(list)) for _, item := range list { - views = append(views, gin.H{"identity": item.Identity, "username": item.Username, "display_name": item.DisplayName, "avatar": item.Avatar, "phone_masked": maskPhone(item.Phone), "platform_role_code": item.PlatformRoleCode, "status": item.Status}) + view := platformAccountView(item) + protectPreciseLocation(ctx, &models.PlatfromAccount{}, view) + views = append(views, view) } infra.Response.Success(ctx, gin.H{"total": total, "list": views}) } @@ -212,22 +331,16 @@ type platfromAccountRequest struct { Password string `json:"password" binding:"required,min=8,max=128"` DisplayName string `json:"display_name" binding:"max=64"` Avatar string `json:"avatar" binding:"max=512"` - PlatformRoleCode string `json:"platform_role_code" binding:"max=64"` + PlatformRoleCode string `json:"platform_role_code" binding:"required,max=64"` Phone string `json:"phone" binding:"max=32"` } -type platfromAccountView struct { - Identity string `json:"identity"` - Username string `json:"username"` - DisplayName string `json:"display_name"` - Avatar string `json:"avatar"` - PhoneMasked string `json:"phone_masked"` - PlatformRoleCode string `json:"platform_role_code"` - Status string `json:"status"` -} - -func platformAccountView(account models.PlatfromAccount) platfromAccountView { - return platfromAccountView{Identity: account.Identity, Username: account.Username, DisplayName: account.DisplayName, Avatar: account.Avatar, PhoneMasked: maskPhone(account.Phone), PlatformRoleCode: account.PlatformRoleCode, Status: account.Status} +func platformAccountView(account models.PlatfromAccount) map[string]any { + return map[string]any{ + "identity": account.Identity, "username": account.Username, + "display_name": account.DisplayName, "avatar": account.Avatar, "phone": account.Phone, + "platform_role_code": account.PlatformRoleCode, "status": account.Status, + } } func GetPlatfromAccount(ctx *gin.Context) { @@ -236,41 +349,66 @@ func GetPlatfromAccount(ctx *gin.Context) { respondRecordError(ctx, err) return } - infra.Response.Success(ctx, platformAccountView(account)) + view := platformAccountView(account) + infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func CreatePlatfromAccount(ctx *gin.Context) { + if !requirePlatformRoot(ctx) { + return + } var request platfromAccountRequest if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if !isAssignablePlatformRole(request.PlatformRoleCode) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } hash, err := passwordHash(request.Password) if err != nil { infra.Response.Error(ctx, err) return } account := models.PlatfromAccount{Entity: newEntity("enabled"), Username: request.Username, DisplayName: request.DisplayName, Avatar: request.Avatar, PasswordHash: hash, PlatformRoleCode: request.PlatformRoleCode, Phone: request.Phone} - if account.PlatformRoleCode == "" { - account.PlatformRoleCode = "root" - } if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, platformAccountView(account)) + view := platformAccountView(account) + infra.Response.Success(ctx, protectPreciseLocation(ctx, &models.PlatfromAccount{}, view)) } func UpdatePlatfromAccount(ctx *gin.Context) { var request struct { - DisplayName string `json:"display_name" binding:"max=64"` - Avatar string `json:"avatar" binding:"max=512"` - PlatformRoleCode string `json:"platform_role_code" binding:"max=64"` - Phone string `json:"phone" binding:"max=32"` + DisplayName string `json:"display_name" binding:"max=64"` + Avatar string `json:"avatar" binding:"max=512"` + PlatformRoleCode *string `json:"platform_role_code" binding:"omitempty,max=64"` + Phone string `json:"phone" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "platform_role_code": request.PlatformRoleCode, "phone": request.Phone}, []string{"display_name", "avatar", "platform_role_code", "phone"}) + values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone} + if request.PlatformRoleCode != nil { + if !requirePlatformRoot(ctx) { + return + } + if !isAssignablePlatformRole(*request.PlatformRoleCode) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values["platform_role_code"] = *request.PlatformRoleCode + } + updateAllowedByIdentity(ctx, &models.PlatfromAccount{}, values, []string{"display_name", "avatar", "platform_role_code", "phone"}) +} + +func isAssignablePlatformRole(roleCode string) bool { + if roleCode == "" || roleCode == "root" { + return false + } + var role models.PlatformRole + return impl.DBService.Where("role_code = ? AND status = ? AND is_system = ?", roleCode, "enabled", false).First(&role).Error == nil } diff --git a/backend/api/internal/logic/platform/secondary_resource.go b/backend/api/internal/logic/platform/secondary_resource.go index 550c819..534ca60 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,12 +25,17 @@ 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 } - infra.Response.Success(ctx, credential) + respondCreatedResource(ctx, credential) } func UpdateStaffCredential(ctx *gin.Context) { var request staffCredentialRequest @@ -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,12 +67,17 @@ 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 } - infra.Response.Success(ctx, address) + respondCreatedResource(ctx, address) } func UpdateUserAddress(ctx *gin.Context) { var request userAddressRequest @@ -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,12 +108,32 @@ 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 } - infra.Response.Success(ctx, relation) + respondCreatedResource(ctx, relation) } func UpdateUserServiceRelation(ctx *gin.Context) { var request userServiceRelationRequest @@ -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..b40b499 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" } @@ -44,23 +54,33 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, staff) + respondCreatedResource(ctx, staff) } // 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 new file mode 100644 index 0000000..71cc671 --- /dev/null +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -0,0 +1,748 @@ +package platform + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "sort" + "strings" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ResourceRelation accepts a stable external identity while retaining the +// relation's database ID as an internal persistence detail. +type ResourceRelation struct { + Input string + Column string + Model any + Required bool +} + +// ResourceHandlers supplies the common identity-based CRUD boundary used by +// platform resources whose writable fields are explicitly declared by routes. +func ResourceHandlers(model any, createFields, updateFields []string, relations ...ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + return func(ctx *gin.Context) { listResource(ctx, model) }, + func(ctx *gin.Context) { createResource(ctx, model, createFields, relations) }, + func(ctx *gin.Context) { getResource(ctx, model) }, + func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) } +} + +// FinSettlementHandlers accepts a public subject_identity and derives the +// polymorphic storage key from its declared subject_type. +func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"} + return func(ctx *gin.Context) { listResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + createResource(ctx, &models.FinSettlement{}, fields, nil) + }, + func(ctx *gin.Context) { getResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateResource(ctx, &models.FinSettlement{}, fields, nil) + } +} + +func rewriteSettlementSubject(ctx *gin.Context) error { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + return err + } + subjectType, _ := input["subject_type"].(string) + identity, _ := input["subject_identity"].(string) + var model any + switch subjectType { + case "gas", "gas_basic": + model = &models.GasBasic{} + case "delivery", "delivery_basic": + model = &models.DeliveryBasic{} + case "staff", "staff_account": + model = &models.StaffAccount{} + default: + return errors.New("invalid settlement subject") + } + id, err := resolveIdentityID(model, identity, true) + if err != nil { + return err + } + delete(input, "subject_identity") + input["subject_id"] = id + encoded, err := json.Marshal(input) + if err != nil { + return err + } + ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) + return nil +} + +func listResource(ctx *gin.Context, model any) { + page, size := pageSize(ctx) + list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem())) + var total int64 + query := applyKeywordFilter(ctx, impl.DBService.Model(model), model) + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(list.Interface()).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := publicResourceResponse(list.Elem().Interface()) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": protectPreciseLocation(ctx, model, response)}) +} + +func getResource(ctx *gin.Context, model any) { + data := reflect.New(reflect.TypeOf(model).Elem()) + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil { + respondRecordError(ctx, err) + return + } + response, err := publicResourceResponse(data.Interface()) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, protectPreciseLocation(ctx, model, response)) +} + +func createResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { + values, err := prepareResourceValues(ctx, model, allowedFields, relations) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + encoded, err := json.Marshal(values) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data := reflect.New(reflect.TypeOf(model).Elem()) + if err := json.Unmarshal(encoded, data.Interface()); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data.Elem().FieldByName("Entity").Set(reflect.ValueOf(newEntity("draft"))) + if err := impl.DBService.Create(data.Interface()).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + respondCreatedResource(ctx, data.Interface()) +} + +func respondCreatedResource(ctx *gin.Context, value any) { + response, err := publicResourceResponse(value) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, maskCreatedSensitiveFields(response)) +} + +func maskCreatedSensitiveFields(value any) any { + switch data := value.(type) { + case map[string]any: + safe := make(map[string]any) + for key, item := range data { + if isCreatedResponseField(key) { + safe[key] = maskCreatedSensitiveFields(item) + } + } + return safe + case []any: + safe := make([]any, len(data)) + for index, item := range data { + safe[index] = maskCreatedSensitiveFields(item) + } + return safe + } + return value +} + +func isCreatedResponseField(key string) bool { + switch key { + case "identity", "status", "version", "created_at", "updated_at": + return true + default: + return strings.HasSuffix(key, "_identity") + } +} + +func protectPreciseLocation(ctx *gin.Context, model, value any) any { + maskPersonalName := reflect.TypeOf(model) == reflect.TypeOf(&models.UserAccount{}) || + reflect.TypeOf(model) == reflect.TypeOf(&models.StaffAccount{}) + maskDisplayName := reflect.TypeOf(model) == reflect.TypeOf(&models.PlatfromAccount{}) + protectPublicFields(value, maskPersonalName, maskDisplayName, hasPreciseLocationScope(ctx)) + return value +} + +var sensitiveResponseFields = map[string]bool{ + "avatar": true, "address": true, "credential_no": true, + "evidence_uri": true, "evidence_url": true, "file_uri": true, + "attachment_uri": true, "attachment_url": true, + "certificate_uri": true, "certificate_url": true, + "credential_uri": true, "credential_url": true, +} + +func protectPublicFields(value any, maskPersonalName, maskDisplayName, retainCoordinates bool) { + switch data := value.(type) { + case map[string]any: + if phone, ok := data["phone"].(string); ok && phone != "" { + data["phone_masked"] = maskPhone(phone) + } + delete(data, "phone") + for key := range sensitiveResponseFields { + delete(data, key) + } + if maskPersonalName { + if name, ok := data["name"].(string); ok && name != "" { + data["name_masked"] = maskPersonalNameValue(name) + } + delete(data, "name") + if name, ok := data["real_name"].(string); ok && name != "" { + data["real_name_masked"] = maskPersonalNameValue(name) + } + delete(data, "real_name") + } + if maskDisplayName { + if name, ok := data["display_name"].(string); ok && name != "" { + data["display_name_masked"] = maskPersonalNameValue(name) + } + delete(data, "display_name") + } + if !retainCoordinates { + delete(data, "longitude") + delete(data, "latitude") + } + for _, item := range data { + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + } + case []any: + for _, item := range data { + protectPublicFields(item, maskPersonalName, maskDisplayName, retainCoordinates) + } + } +} + +func maskPersonalNameValue(name string) string { + runes := []rune(name) + if len(runes) == 0 { + return "" + } + if len(runes) == 1 { + return "*" + } + return string(runes[0]) + strings.Repeat("*", len(runes)-1) +} + +func hasPreciseLocationScope(ctx *gin.Context) bool { + claims, err := middleware.ParseAuth(ctx) + return err == nil && claims.Extend["location_scope"] == "precise" +} + +func updateResource(ctx *gin.Context, model any, allowedFields []string, relations []ResourceRelation) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values, err := resolveResourceRelations(input, allowedFields, relations, false) + if err != nil || normalizeStringJSONBFields(model, values) != 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, model any, 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 || normalizeStringJSONBFields(model, values) != nil || len(values) == 0 { + return nil, errors.New("invalid resource payload") + } + return values, nil +} + +func normalizeStringJSONBFields(model any, values map[string]any) error { + modelType := reflect.TypeOf(model) + for modelType.Kind() == reflect.Pointer { + modelType = modelType.Elem() + } + for index := 0; index < modelType.NumField(); index++ { + field := modelType.Field(index) + if field.Type.Kind() != reflect.String || !strings.Contains(field.Tag.Get("gorm"), "type:jsonb") { + continue + } + column := gormColumn(field.Tag.Get("gorm")) + value, exists := values[column] + if !exists { + continue + } + if text, ok := value.(string); ok { + if !json.Valid([]byte(text)) { + return errors.New("invalid jsonb string") + } + continue + } + encoded, err := json.Marshal(value) + if err != nil || !json.Valid(encoded) { + return errors.New("invalid jsonb value") + } + values[column] = string(encoded) + } + return nil +} + +func resolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) { + values := filterFields(input, allowedFields) + for _, relation := range relations { + raw, exists := input[relation.Input] + if !exists { + if requireRelations && relation.Required { + return nil, errors.New("missing required relation") + } + continue + } + identity, ok := raw.(string) + if !ok || strings.TrimSpace(identity) == "" { + return nil, errors.New("invalid relation identity") + } + id, err := resolveIdentityID(relation.Model, identity, true) + if err != nil { + return nil, err + } + values[relation.Column] = id + } + return values, nil +} + +// resolveIdentityID is the only boundary that converts a public identity to a +// persistence-only numeric key. Callers must never bind a client supplied ID. +func resolveIdentityID(model any, identity string, required bool) (uint64, error) { + identity = strings.TrimSpace(identity) + if identity == "" { + if required { + return 0, errors.New("missing required relation") + } + return 0, nil + } + var related struct{ ID uint64 } + if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil { + return 0, err + } + return related.ID, nil +} + +func relationColumns(relations []ResourceRelation) []string { + columns := make([]string, 0, len(relations)) + for _, relation := range relations { + columns = append(columns, relation.Column) + } + return columns +} + +// resourceResponse strips database surrogate IDs from API data. Business +// identities are the only public relation keys accepted or returned. +func resourceResponse(value any) any { + encoded, err := json.Marshal(value) + if err != nil { + return value + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return value + } + return stripInternalIDs(decoded) +} + +// publicResourceResponse additionally resolves persisted relation keys into +// their public identities. It is used by list/detail endpoints so an edit form +// can round-trip the relation without ever receiving a surrogate database ID. +func publicResourceResponse(value any) (any, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + var decoded any + if err := json.Unmarshal(encoded, &decoded); err != nil { + return nil, err + } + return projectRelationIdentities(decoded) +} + +var relationIdentityModels = map[string]any{ + "gas_basic_id": &models.GasBasic{}, + "gas_station_id": &models.GasBasic{}, + "delivery_basic_id": &models.DeliveryBasic{}, + "delivery_point_id": &models.DeliveryBasic{}, + "user_account_id": &models.UserAccount{}, + "staff_account_id": &models.StaffAccount{}, + "smart_cylinder_valve_id": &models.DevSmartCylinderValve{}, + "ec_category_id": &models.EcCategory{}, + "ec_product_id": &models.EcProduct{}, + "ec_order_id": &models.EcOrder{}, + "delivery_task_id": &models.DeliveryTask{}, + "delivery_track_id": &models.DeliveryTrack{}, + "platform_role_id": &models.PlatformRole{}, + "platform_menu_id": &models.PlatformMenu{}, + "report_id": &models.Report{}, + "wallet_id": &models.Wallet{}, +} + +var relationIdentityKeys = map[string]string{ + "gas_station_id": "gas_basic_identity", + "delivery_point_id": "delivery_basic_identity", +} + +type relationIdentityReference struct { + target map[string]any + identityKey string + id uint64 +} + +type relationIdentityGroup struct { + model any + ids []uint64 + seen map[uint64]struct{} + references []relationIdentityReference +} + +type relationIdentityRecord struct { + ID uint64 + Identity string +} + +func projectRelationIdentities(value any) (any, error) { + groups := map[string]*relationIdentityGroup{} + collectRelationIdentityReferences(value, groups) + groupKeys := make([]string, 0, len(groups)) + for key := range groups { + groupKeys = append(groupKeys, key) + } + sort.Strings(groupKeys) + for _, key := range groupKeys { + group := groups[key] + var rows []relationIdentityRecord + if err := impl.DBService.Model(group.model).Select("id", "identity").Where("id IN ?", group.ids).Find(&rows).Error; err != nil { + return nil, err + } + identities := make(map[uint64]string, len(rows)) + for _, row := range rows { + identities[row.ID] = row.Identity + } + for _, reference := range group.references { + identity, found := identities[reference.id] + if !found { + return nil, errors.New("related identity not found") + } + reference.target[reference.identityKey] = identity + } + } + return value, nil +} + +func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup) { + switch data := value.(type) { + case map[string]any: + for key, item := range data { + if key == "id" { + delete(data, key) + continue + } + if strings.HasSuffix(key, "_id") { + identityKey := strings.TrimSuffix(key, "_id") + "_identity" + if alias := relationIdentityKeys[key]; alias != "" { + identityKey = alias + } + model := relationIdentityModels[key] + if key == "subject_id" { + model = settlementSubjectModel(data["subject_type"]) + identityKey = "subject_identity" + } + if model != nil { + if id, ok := responseRelationID(item); ok && id != 0 { + key := reflect.TypeOf(model).String() + group := groups[key] + if group == nil { + group = &relationIdentityGroup{model: model, seen: map[uint64]struct{}{}} + groups[key] = group + } + if _, found := group.seen[id]; !found { + group.ids = append(group.ids, id) + group.seen[id] = struct{}{} + } + group.references = append(group.references, relationIdentityReference{target: data, identityKey: identityKey, id: id}) + } else { + data[identityKey] = "" + } + } + delete(data, key) + continue + } + collectRelationIdentityReferences(item, groups) + } + case []any: + for _, item := range data { + collectRelationIdentityReferences(item, groups) + } + } +} + +func responseRelationID(value any) (uint64, bool) { + var id uint64 + switch raw := value.(type) { + case float64: + id = uint64(raw) + case uint64: + id = raw + case int: + id = uint64(raw) + default: + return 0, false + } + return id, true +} + +func settlementSubjectModel(value any) any { + subjectType, _ := value.(string) + switch subjectType { + case "gas", "gas_basic": + return &models.GasBasic{} + case "delivery", "delivery_basic": + return &models.DeliveryBasic{} + case "staff", "staff_account": + return &models.StaffAccount{} + default: + return nil + } +} + +func stripInternalIDs(value any) any { + switch data := value.(type) { + case map[string]any: + for key, item := range data { + if key == "id" || strings.HasSuffix(key, "_id") { + delete(data, key) + continue + } + data[key] = stripInternalIDs(item) + } + case []any: + for index := range data { + data[index] = stripInternalIDs(data[index]) + } + } + return value +} + +// DisposeSafetyEvent atomically updates an event and appends its operator-owned +// action record. Disposal records deliberately have no update or delete route. +func 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 { + infra.Response.Error(ctx, err) + return + } + var request struct { + Action string `json:"action" binding:"required,max=64"` + Reason string `json:"reason" binding:"max=2000"` + Status string `json:"status" binding:"max=32"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if request.Status == "" { + request.Status = "disposed" + } + var disposal models.SafEventDisposal + err = impl.DBService.Transaction(func(transaction *gorm.DB) error { + var event models.SafEvent + if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&event).Error; err != nil { + return err + } + if result := transaction.Model(&models.SafEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil { + return result.Error + } else if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + disposal = models.SafEventDisposal{ + Entity: newEntity("enabled"), + SafEventIdentity: event.Identity, + Action: request.Action, + Reason: request.Reason, + OperatorIdentity: claims.Identity, + } + return transaction.Create(&disposal).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, resourceResponse(disposal)) +} + +// GetEcOrder returns the order together with its immutable item snapshots. +func GetEcOrder(ctx *gin.Context) { + var order models.EcOrder + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + respondRecordError(ctx, err) + return + } + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := publicResourceResponse(gin.H{"order": order, "items": items}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) +} + +// GetDeliveryTrack returns time-ordered points. Precise coordinates are only +// exposed to tokens explicitly granted the location_scope=precise claim. +func GetDeliveryTrack(ctx *gin.Context) { + var track models.DeliveryTrack + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil { + respondRecordError(ctx, err) + return + } + var points []models.DeliveryTrackPoint + if err := impl.DBService.Where("delivery_track_id = ?", track.ID).Order("occurred_at asc").Find(&points).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if !hasPreciseLocationScope(ctx) { + for index := range points { + points[index].Longitude = "" + points[index].Latitude = "" + } + } + response, err := publicResourceResponse(gin.H{"track": track, "points": points}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, response) +} + +type ecCategoryView struct { + Identity string `json:"identity"` + ParentIdentity string `json:"parent_identity,omitempty"` + Name string `json:"name"` + SortNo int `json:"sort_no"` + Status string `json:"status"` +} + +func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) { + parents := make(map[uint64]string) + for _, item := range list { + if item.ParentID != 0 { + parents[item.ParentID] = "" + } + } + if len(parents) > 0 { + var rows []struct { + ID uint64 + Identity string + } + ids := make([]uint64, 0, len(parents)) + for id := range parents { + ids = append(ids, id) + } + if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + parents[row.ID] = row.Identity + } + } + views := make([]ecCategoryView, 0, len(list)) + for _, item := range list { + views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status}) + } + return views, nil +} + +func ListEcCategory(ctx *gin.Context) { + page, size := pageSize(ctx) + var list []models.EcCategory + var total int64 + if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + views, err := ecCategoryViews(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": views}) +} + +func GetEcCategory(ctx *gin.Context) { + var category models.EcCategory + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { + respondRecordError(ctx, err) + return + } + views, err := ecCategoryViews([]models.EcCategory{category}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, views[0]) +} diff --git a/backend/api/internal/logic/platform/user.go b/backend/api/internal/logic/platform/user.go index 06fb217..a47090e 100644 --- a/backend/api/internal/logic/platform/user.go +++ b/backend/api/internal/logic/platform/user.go @@ -38,7 +38,7 @@ func CreateUser(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, user) + respondCreatedResource(ctx, user) } // UpdateUser 更新业主客户档案。 diff --git a/backend/api/internal/models/aud_approval.go b/backend/api/internal/models/aud_approval.go index a028fc3..e082c40 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"` + Entity // 公共实体字段 + BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` // business_type 业务字段 + BusinessIdentity string `gorm:"column:business_identity;type:varchar(36);not null;index" json:"business_identity"` // business_identity 业务字段 + ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` // applicant_identity 业务字段 + Opinion string `gorm:"column:opinion;type:text;not null;default:''" json:"opinion"` // opinion 业务字段 + HandlerIdentity string `gorm:"column:handler_identity;type:varchar(36);not null;default:'';index" json:"handler_identity"` // handler_identity 业务字段 + HandledAt *time.Time `gorm:"column:handled_at;type:timestamptz" json:"handled_at"` // handled_at 业务字段 } func init() { database.AppendMigrate(&AudApproval{}) } diff --git a/backend/api/internal/models/aud_export_log.go b/backend/api/internal/models/aud_export_log.go index 6374886..97b11b7 100644 --- a/backend/api/internal/models/aud_export_log.go +++ b/backend/api/internal/models/aud_export_log.go @@ -7,12 +7,12 @@ import ( // AudExportLog 对应 aud_export_log,保存敏感导出审计。 type AudExportLog struct { - Entity - ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` - Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"` - FieldScope string `gorm:"column:field_scope;type:jsonb;not null;default:'{}'" json:"field_scope"` - ApprovedAt *time.Time `gorm:"column:approved_at;type:timestamptz" json:"approved_at"` - FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"` + Entity // 公共实体字段 + ApplicantIdentity string `gorm:"column:applicant_identity;type:varchar(36);not null;index" json:"applicant_identity"` // applicant_identity 业务字段 + Purpose string `gorm:"column:purpose;type:varchar(255);not null" json:"purpose"` // purpose 业务字段 + FieldScope string `gorm:"column:field_scope;type:jsonb;not null;default:'{}'" json:"field_scope"` // field_scope 业务字段 + ApprovedAt *time.Time `gorm:"column:approved_at;type:timestamptz" json:"approved_at"` // approved_at 业务字段 + FileURI string `gorm:"column:file_uri;type:varchar(512);not null;default:''" json:"file_uri"` // file_uri 业务字段 } func init() { database.AppendMigrate(&AudExportLog{}) } diff --git a/backend/api/internal/models/aud_operation_log.go b/backend/api/internal/models/aud_operation_log.go index 2aaca45..c7fb70c 100644 --- a/backend/api/internal/models/aud_operation_log.go +++ b/backend/api/internal/models/aud_operation_log.go @@ -4,13 +4,13 @@ import "git.apinb.com/bsm-sdk/core/database" // AudOperationLog 对应 aud_operation_log,保存不可变操作审计。 type AudOperationLog struct { - Entity - OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` - Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` - ObjectType string `gorm:"column:object_type;type:varchar(64);not null" json:"object_type"` - ObjectIdentity string `gorm:"column:object_identity;type:varchar(36);not null;index" json:"object_identity"` - BeforeData string `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"` - AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"` + Entity // 公共实体字段 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;index" json:"operator_identity"` // operator_identity 业务字段 + Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段 + ObjectType string `gorm:"column:object_type;type:varchar(64);not null" json:"object_type"` // object_type 业务字段 + ObjectIdentity string `gorm:"column:object_identity;type:varchar(36);not null;index" json:"object_identity"` // object_identity 业务字段 + BeforeData string `gorm:"column:before_data;type:jsonb;not null;default:'{}'" json:"before_data"` // before_data 业务字段 + AfterData string `gorm:"column:after_data;type:jsonb;not null;default:'{}'" json:"after_data"` // after_data 业务字段 } func init() { database.AppendMigrate(&AudOperationLog{}) } diff --git a/backend/api/internal/models/cnt_content.go b/backend/api/internal/models/cnt_content.go index ccaefc6..b81c67e 100644 --- a/backend/api/internal/models/cnt_content.go +++ b/backend/api/internal/models/cnt_content.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // CntContent 对应 cnt_content,保存公告与协议内容。 type CntContent struct { - Entity - ContentType string `gorm:"column:content_type;type:varchar(32);not null" json:"content_type"` - Title string `gorm:"column:title;type:varchar(256);not null" json:"title"` - Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` - VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` - PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"` + Entity // 公共实体字段 + ContentType string `gorm:"column:content_type;type:varchar(32);not null" json:"content_type"` // content_type 业务字段 + Title string `gorm:"column:title;type:varchar(256);not null" json:"title"` // title 业务字段 + Body string `gorm:"column:body;type:text;not null;default:''" json:"body"` // body 业务字段 + VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` // version_no 业务字段 + PublishStatus string `gorm:"column:publish_status;type:varchar(32);not null;default:'draft'" json:"publish_status"` // publish_status 业务字段 } func init() { database.AppendMigrate(&CntContent{}) } diff --git a/backend/api/internal/models/comments_test.go b/backend/api/internal/models/comments_test.go new file mode 100644 index 0000000..3f782c6 --- /dev/null +++ b/backend/api/internal/models/comments_test.go @@ -0,0 +1,53 @@ +package models + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "regexp" + "testing" +) + +var chineseText = regexp.MustCompile(`[\p{Han}]`) + +func TestEveryModelFieldHasChineseComment(t *testing.T) { + packages, err := parser.ParseDir(token.NewFileSet(), ".", func(info os.FileInfo) bool { + return info.Name() != "comments_test.go" + }, parser.ParseComments) + if err != nil { + t.Fatal(err) + } + for _, file := range packages["models"].Files { + ast.Inspect(file, func(node ast.Node) bool { + typeSpec, ok := node.(*ast.TypeSpec) + if !ok { + return true + } + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return false + } + for _, field := range structType.Fields.List { + name := "embedded field" + if len(field.Names) > 0 { + name = field.Names[0].Name + if !ast.IsExported(name) { + continue + } + } + comment := "" + if field.Doc != nil { + comment += field.Doc.Text() + } + if field.Comment != nil { + comment += field.Comment.Text() + } + if !chineseText.MatchString(comment) { + t.Errorf("%s.%s 缺少中文字段注释", typeSpec.Name.Name, name) + } + } + return false + }) + } +} diff --git a/backend/api/internal/models/cs_ticket.go b/backend/api/internal/models/cs_ticket.go index e8a1f51..ed6cc0e 100644 --- a/backend/api/internal/models/cs_ticket.go +++ b/backend/api/internal/models/cs_ticket.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // CsTicket 对应 cs_ticket,保存客服工单。 type CsTicket struct { - Entity - TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` - Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` + Entity // 公共实体字段 + TicketNo string `gorm:"column:ticket_no;type:varchar(64);not null;uniqueIndex" json:"ticket_no"` // ticket_no 业务字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + Category string `gorm:"column:category;type:varchar(64);not null" json:"category"` // category 业务字段 + Priority string `gorm:"column:priority;type:varchar(16);not null;default:'normal'" json:"priority"` // priority 业务字段 } func init() { database.AppendMigrate(&CsTicket{}) } diff --git a/backend/api/internal/models/delivery_account.go b/backend/api/internal/models/delivery_account.go index e093c0c..ac60372 100644 --- a/backend/api/internal/models/delivery_account.go +++ b/backend/api/internal/models/delivery_account.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // DeliveryAccount 对应 delivery_account,保存配送点登录账户。 type DeliveryAccount struct { - Entity - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"` - Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` - DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` - RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"` + Entity // 公共实体字段 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;index" json:"delivery_basic_id"` // delivery_basic_id 业务字段 + Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // username 业务字段 + DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // display_name 业务字段 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // PasswordHash 业务字段 + RoleCode string `gorm:"column:role_code;type:varchar(64);not null" json:"role_code"` // role_code 业务字段 } func init() { database.AppendMigrate(&DeliveryAccount{}) } diff --git a/backend/api/internal/models/delivery_basic.go b/backend/api/internal/models/delivery_basic.go index 4746829..046c727 100644 --- a/backend/api/internal/models/delivery_basic.go +++ b/backend/api/internal/models/delivery_basic.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // DeliveryBasic 对应 delivery_basic,保存配送点主档案。 type DeliveryBasic struct { - Entity + Entity // 公共实体字段 DeliveryCode string `gorm:"column:delivery_code;type:varchar(32);not null;uniqueIndex" json:"delivery_code"` // 配送点编码 GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // 所属可燃气体站自增主键,0 表示平台直属 Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 配送点名称 diff --git a/backend/api/internal/models/delivery_task.go b/backend/api/internal/models/delivery_task.go index 1479178..9dd4d14 100644 --- a/backend/api/internal/models/delivery_task.go +++ b/backend/api/internal/models/delivery_task.go @@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database" // DeliveryTask 对应 delivery_task,保存配送履约任务。 type DeliveryTask struct { - Entity - EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` - DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"` + Entity // 公共实体字段 + EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // staff_account_id 业务字段 + DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;index" json:"delivery_point_id"` // delivery_point_id 业务字段 } func init() { database.AppendMigrate(&DeliveryTask{}) } diff --git a/backend/api/internal/models/delivery_track.go b/backend/api/internal/models/delivery_track.go index bddf6aa..6af10d1 100644 --- a/backend/api/internal/models/delivery_track.go +++ b/backend/api/internal/models/delivery_track.go @@ -7,10 +7,10 @@ import ( // DeliveryTrack 对应 delivery_track,保存配送轨迹摘要。 type DeliveryTrack struct { - Entity - DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"` - StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` - CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` + Entity // 公共实体字段 + DeliveryTaskID uint64 `gorm:"column:delivery_task_id;not null;index" json:"delivery_task_id"` // delivery_task_id 业务字段 + StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at"` // started_at 业务字段 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // completed_at 业务字段 } func init() { database.AppendMigrate(&DeliveryTrack{}) } diff --git a/backend/api/internal/models/delivery_track_point.go b/backend/api/internal/models/delivery_track_point.go index 3ad0282..4e8f446 100644 --- a/backend/api/internal/models/delivery_track_point.go +++ b/backend/api/internal/models/delivery_track_point.go @@ -7,12 +7,12 @@ import ( // DeliveryTrackPoint 对应 delivery_track_point,保存配送节点和位置。 type DeliveryTrackPoint struct { - Entity - DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"` - PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"` - OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"` - Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` - Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` + Entity // 公共实体字段 + DeliveryTrackID uint64 `gorm:"column:delivery_track_id;not null;index" json:"delivery_track_id"` // delivery_track_id 业务字段 + PointType string `gorm:"column:point_type;type:varchar(32);not null" json:"point_type"` // point_type 业务字段 + OccurredAt time.Time `gorm:"column:occurred_at;type:timestamptz;not null" json:"occurred_at"` // occurred_at 业务字段 + Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段 + Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段 } func init() { database.AppendMigrate(&DeliveryTrackPoint{}) } diff --git a/backend/api/internal/models/dev_device_binding.go b/backend/api/internal/models/dev_device_binding.go index 4b2afb6..cbffcb6 100644 --- a/backend/api/internal/models/dev_device_binding.go +++ b/backend/api/internal/models/dev_device_binding.go @@ -7,11 +7,11 @@ import ( // DevDeviceBinding 对应 dev_device_binding,保存设备授权绑定。 type DevDeviceBinding struct { - Entity - SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"` - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` - ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` + Entity // 公共实体字段 + SmartCylinderValveID uint64 `gorm:"column:smart_cylinder_valve_id;not null;index" json:"smart_cylinder_valve_id"` // smart_cylinder_valve_id 业务字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + EffectiveAt time.Time `gorm:"column:effective_at;type:timestamptz;not null" json:"effective_at"` // effective_at 业务字段 + ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段 } func init() { database.AppendMigrate(&DevDeviceBinding{}) } diff --git a/backend/api/internal/models/dev_smart_cylinder_valve.go b/backend/api/internal/models/dev_smart_cylinder_valve.go index ad89771..a824531 100644 --- a/backend/api/internal/models/dev_smart_cylinder_valve.go +++ b/backend/api/internal/models/dev_smart_cylinder_valve.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // DevSmartCylinderValve 对应 dev_smart_cylinder_valve,保存智能瓶阀档案。 type DevSmartCylinderValve struct { - Entity - DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"` - Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"` - OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"` - OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"` + Entity // 公共实体字段 + DeviceNo string `gorm:"column:device_no;type:varchar(64);not null;uniqueIndex" json:"device_no"` // device_no 业务字段 + Model string `gorm:"column:model;type:varchar(64);not null;default:''" json:"model"` // model 业务字段 + OnlineStatus string `gorm:"column:online_status;type:varchar(32);not null;default:'offline'" json:"online_status"` // online_status 业务字段 + OwnerIdentity string `gorm:"column:owner_identity;type:varchar(36);not null;default:'';index" json:"owner_identity"` // owner_identity 业务字段 } func init() { database.AppendMigrate(&DevSmartCylinderValve{}) } diff --git a/backend/api/internal/models/dev_telemetry.go b/backend/api/internal/models/dev_telemetry.go index 1aea5fa..cbf2454 100644 --- a/backend/api/internal/models/dev_telemetry.go +++ b/backend/api/internal/models/dev_telemetry.go @@ -7,11 +7,11 @@ import ( // DevTelemetry 对应 dev_telemetry,保存设备遥测摘要。 type DevTelemetry struct { - Entity - SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"` - ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"` - Payload string `gorm:"column:payload;type:jsonb;not null;default:'{}'" json:"payload"` - QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` + Entity // 公共实体字段 + SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段 + ReportedAt time.Time `gorm:"column:reported_at;type:timestamptz;not null;index" json:"reported_at"` // reported_at 业务字段 + Payload string `gorm:"column:payload;type:jsonb;not null;default:'{}'" json:"payload"` // payload 业务字段 + QualityFlag string `gorm:"column:quality_flag;type:varchar(32);not null;default:'normal'" json:"quality_flag"` // quality_flag 业务字段 } func init() { database.AppendMigrate(&DevTelemetry{}) } diff --git a/backend/api/internal/models/ec_cart.go b/backend/api/internal/models/ec_cart.go index 46623e4..067ead7 100644 --- a/backend/api/internal/models/ec_cart.go +++ b/backend/api/internal/models/ec_cart.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // EcCart 对应 ec_cart,保存用户购物车明细。 type EcCart struct { - Entity - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` - Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` - Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段 + Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段 + Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` // selected 业务字段 } func init() { database.AppendMigrate(&EcCart{}) } diff --git a/backend/api/internal/models/ec_category.go b/backend/api/internal/models/ec_category.go index 1b48ad1..ce2b0b3 100644 --- a/backend/api/internal/models/ec_category.go +++ b/backend/api/internal/models/ec_category.go @@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database" // EcCategory 对应 ec_category,保存商品分类树。 type EcCategory struct { - Entity - ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` - Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` - SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` + Entity // 公共实体字段 + ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` // parent_id 业务字段 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段 + SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段 } func init() { database.AppendMigrate(&EcCategory{}) } diff --git a/backend/api/internal/models/ec_order.go b/backend/api/internal/models/ec_order.go index bb21b82..734ab8e 100644 --- a/backend/api/internal/models/ec_order.go +++ b/backend/api/internal/models/ec_order.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // EcOrder 对应 ec_order,保存电商订单与组织快照。 type EcOrder struct { - Entity - OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` - DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` - TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"` + Entity // 公共实体字段 + OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // order_no 业务字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + GasStationID uint64 `gorm:"column:gas_station_id;not null;default:0;index" json:"gas_station_id"` // gas_station_id 业务字段 + DeliveryPointID uint64 `gorm:"column:delivery_point_id;not null;default:0;index" json:"delivery_point_id"` // delivery_point_id 业务字段 + TotalAmount int64 `gorm:"column:total_amount;not null;default:0" json:"total_amount"` // total_amount 业务字段 } func init() { database.AppendMigrate(&EcOrder{}) } diff --git a/backend/api/internal/models/ec_order_item.go b/backend/api/internal/models/ec_order_item.go index 7d60a9d..88b62fb 100644 --- a/backend/api/internal/models/ec_order_item.go +++ b/backend/api/internal/models/ec_order_item.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // EcOrderItem 对应 ec_order_item,保存订单商品快照。 type EcOrderItem struct { - Entity - EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` - EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` - ProductSnapshot string `gorm:"column:product_snapshot;type:jsonb;not null;default:'{}'" json:"product_snapshot"` - Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` - SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"` + Entity // 公共实体字段 + EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段 + EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段 + ProductSnapshot string `gorm:"column:product_snapshot;type:jsonb;not null;default:'{}'" json:"product_snapshot"` // product_snapshot 业务字段 + Quantity int `gorm:"column:quantity;not null;default:1" json:"quantity"` // quantity 业务字段 + SaleAmount int64 `gorm:"column:sale_amount;not null;default:0" json:"sale_amount"` // sale_amount 业务字段 } func init() { database.AppendMigrate(&EcOrderItem{}) } diff --git a/backend/api/internal/models/ec_product.go b/backend/api/internal/models/ec_product.go index 9f6b40e..518a219 100644 --- a/backend/api/internal/models/ec_product.go +++ b/backend/api/internal/models/ec_product.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // EcProduct 对应 ec_product,保存可燃气体商品与服务。 type EcProduct struct { - Entity - EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"` - ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"` - Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` - PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"` - StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"` + Entity // 公共实体字段 + EcCategoryID uint64 `gorm:"column:ec_category_id;not null;index" json:"ec_category_id"` // ec_category_id 业务字段 + ProductCode string `gorm:"column:product_code;type:varchar(64);not null;uniqueIndex" json:"product_code"` // product_code 业务字段 + Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // name 业务字段 + PriceAmount int64 `gorm:"column:price_amount;not null;default:0" json:"price_amount"` // price_amount 业务字段 + StockQuantity int `gorm:"column:stock_quantity;not null;default:0" json:"stock_quantity"` // stock_quantity 业务字段 } func init() { database.AppendMigrate(&EcProduct{}) } diff --git a/backend/api/internal/models/ec_product_attribute.go b/backend/api/internal/models/ec_product_attribute.go index 56e3abb..bb0dd05 100644 --- a/backend/api/internal/models/ec_product_attribute.go +++ b/backend/api/internal/models/ec_product_attribute.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // EcProductAttribute 对应 ec_product_attribute,保存商品属性。 type EcProductAttribute struct { - Entity - EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` - Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` - Value string `gorm:"column:value;type:varchar(255);not null" json:"value"` - SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` + Entity // 公共实体字段 + EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段 + Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // name 业务字段 + Value string `gorm:"column:value;type:varchar(255);not null" json:"value"` // value 业务字段 + SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段 } func init() { database.AppendMigrate(&EcProductAttribute{}) } diff --git a/backend/api/internal/models/ec_product_image.go b/backend/api/internal/models/ec_product_image.go index ec52f1b..0bf11e5 100644 --- a/backend/api/internal/models/ec_product_image.go +++ b/backend/api/internal/models/ec_product_image.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // EcProductImage 对应 ec_product_image,保存商品受控图片资源。 type EcProductImage struct { - Entity - EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` - ImageURI string `gorm:"column:image_uri;type:varchar(512);not null" json:"image_uri"` - SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` - IsCover bool `gorm:"column:is_cover;not null;default:false" json:"is_cover"` + Entity // 公共实体字段 + EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段 + ImageURI string `gorm:"column:image_uri;type:varchar(512);not null" json:"image_uri"` // image_uri 业务字段 + SortNo int `gorm:"column:sort_no;not null;default:0" json:"sort_no"` // sort_no 业务字段 + IsCover bool `gorm:"column:is_cover;not null;default:false" json:"is_cover"` // is_cover 业务字段 } func init() { database.AppendMigrate(&EcProductImage{}) } diff --git a/backend/api/internal/models/ec_review.go b/backend/api/internal/models/ec_review.go index 5f83a85..ff5e932 100644 --- a/backend/api/internal/models/ec_review.go +++ b/backend/api/internal/models/ec_review.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // EcReview 对应 ec_review,保存商品评论与审核状态。 type EcReview struct { - Entity - EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` - EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - Score int `gorm:"column:score;not null;default:5" json:"score"` - Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` + Entity // 公共实体字段 + EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段 + EcProductID uint64 `gorm:"column:ec_product_id;not null;index" json:"ec_product_id"` // ec_product_id 业务字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + Score int `gorm:"column:score;not null;default:5" json:"score"` // score 业务字段 + Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // content 业务字段 } func init() { database.AppendMigrate(&EcReview{}) } diff --git a/backend/api/internal/models/fin_payment.go b/backend/api/internal/models/fin_payment.go index cdc004a..b4df3ca 100644 --- a/backend/api/internal/models/fin_payment.go +++ b/backend/api/internal/models/fin_payment.go @@ -7,11 +7,11 @@ import ( // FinPayment 对应 fin_payment,保存支付与退款记录。 type FinPayment struct { - Entity - EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` - Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` - Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` - PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` + Entity // 公共实体字段 + EcOrderID uint64 `gorm:"column:ec_order_id;not null;index" json:"ec_order_id"` // ec_order_id 业务字段 + Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段 + Amount int64 `gorm:"column:amount;not null;default:0" json:"amount"` // amount 业务字段 + PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // paid_at 业务字段 } func init() { database.AppendMigrate(&FinPayment{}) } diff --git a/backend/api/internal/models/fin_reconciliation.go b/backend/api/internal/models/fin_reconciliation.go index 0369a46..f61df69 100644 --- a/backend/api/internal/models/fin_reconciliation.go +++ b/backend/api/internal/models/fin_reconciliation.go @@ -7,10 +7,10 @@ import ( // FinReconciliation 对应 fin_reconciliation,保存渠道对账记录。 type FinReconciliation struct { - Entity - Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"` - BillDate time.Time `gorm:"column:bill_date;type:date;not null" json:"bill_date"` - DifferenceAmount int64 `gorm:"column:difference_amount;not null;default:0" json:"difference_amount"` + Entity // 公共实体字段 + Channel string `gorm:"column:channel;type:varchar(32);not null;index" json:"channel"` // channel 业务字段 + BillDate time.Time `gorm:"column:bill_date;type:date;not null" json:"bill_date"` // bill_date 业务字段 + DifferenceAmount int64 `gorm:"column:difference_amount;not null;default:0" json:"difference_amount"` // difference_amount 业务字段 } func init() { database.AppendMigrate(&FinReconciliation{}) } diff --git a/backend/api/internal/models/fin_settlement.go b/backend/api/internal/models/fin_settlement.go index 664c104..8d2ee3f 100644 --- a/backend/api/internal/models/fin_settlement.go +++ b/backend/api/internal/models/fin_settlement.go @@ -7,12 +7,12 @@ import ( // FinSettlement 对应 fin_settlement,保存结算单。 type FinSettlement struct { - Entity - SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"` - SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"` - SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"` - PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"` - PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"` + Entity // 公共实体字段 + SettlementNo string `gorm:"column:settlement_no;type:varchar(64);not null;uniqueIndex" json:"settlement_no"` // settlement_no 业务字段 + SubjectType string `gorm:"column:subject_type;type:varchar(32);not null" json:"subject_type"` // subject_type 业务字段 + SubjectID uint64 `gorm:"column:subject_id;not null;index" json:"subject_id"` // subject_id 业务字段 + PeriodStart time.Time `gorm:"column:period_start;type:timestamptz;not null" json:"period_start"` // period_start 业务字段 + PeriodEnd time.Time `gorm:"column:period_end;type:timestamptz;not null" json:"period_end"` // period_end 业务字段 } func init() { database.AppendMigrate(&FinSettlement{}) } diff --git a/backend/api/internal/models/gas_account.go b/backend/api/internal/models/gas_account.go index 5b1c3bd..bbc4c64 100644 --- a/backend/api/internal/models/gas_account.go +++ b/backend/api/internal/models/gas_account.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // GasAccount 对应 gas_account,保存可燃气体站登录账户。 type GasAccount struct { - Entity + Entity // 公共实体字段 GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 可燃气体站主键 Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 展示名称 diff --git a/backend/api/internal/models/gas_basic.go b/backend/api/internal/models/gas_basic.go index 17db459..c6f81a6 100644 --- a/backend/api/internal/models/gas_basic.go +++ b/backend/api/internal/models/gas_basic.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // GasBasic 对应 gas_basic,保存可燃气体站的主体主档案。 type GasBasic struct { - Entity + Entity // 公共实体字段 Code string `gorm:"column:code;type:varchar(32);not null;uniqueIndex" json:"code"` // 站点编码 Name string `gorm:"column:name;type:varchar(128);not null" json:"name"` // 站点名称 CreditCode string `gorm:"column:credit_code;type:varchar(64);not null;default:''" json:"credit_code"` // 统一社会信用代码 diff --git a/backend/api/internal/models/ntf_template.go b/backend/api/internal/models/ntf_template.go index 3f5050d..c783d71 100644 --- a/backend/api/internal/models/ntf_template.go +++ b/backend/api/internal/models/ntf_template.go @@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database" // NtfTemplate 对应 ntf_template,保存通知模板。 type NtfTemplate struct { - Entity - TemplateCode string `gorm:"column:template_code;type:varchar(64);not null;uniqueIndex" json:"template_code"` - Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` - Content string `gorm:"column:content;type:text;not null" json:"content"` + Entity // 公共实体字段 + TemplateCode string `gorm:"column:template_code;type:varchar(64);not null;uniqueIndex" json:"template_code"` // template_code 业务字段 + Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段 + Content string `gorm:"column:content;type:text;not null" json:"content"` // content 业务字段 } func init() { database.AppendMigrate(&NtfTemplate{}) } diff --git a/backend/api/internal/models/platform_menu.go b/backend/api/internal/models/platform_menu.go index b875dde..2ca6268 100644 --- a/backend/api/internal/models/platform_menu.go +++ b/backend/api/internal/models/platform_menu.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // PlatformMenu 对应 platform_menu,定义平台总后台的菜单树和访问路由。 type PlatformMenu struct { - Entity + Entity // 公共实体字段 ParentID uint64 `gorm:"column:parent_id;not null;default:0;index" json:"parent_id"` // 父菜单自增主键,顶级菜单为 0 MenuCode string `gorm:"column:menu_code;type:varchar(64);not null;uniqueIndex" json:"menu_code"` // 菜单编码 Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 菜单名称 diff --git a/backend/api/internal/models/platform_role.go b/backend/api/internal/models/platform_role.go index 6b08a8f..4e4625e 100644 --- a/backend/api/internal/models/platform_role.go +++ b/backend/api/internal/models/platform_role.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // PlatformRole 对应 platform_role,定义平台总后台的数据范围与菜单权限角色。 type PlatformRole struct { - Entity + Entity // 公共实体字段 RoleCode string `gorm:"column:role_code;type:varchar(64);not null;uniqueIndex" json:"role_code"` // 角色编码 Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 角色名称 DataScope string `gorm:"column:data_scope;type:varchar(32);not null;default:'global'" json:"data_scope"` // 数据权限范围 diff --git a/backend/api/internal/models/platfrom_account.go b/backend/api/internal/models/platfrom_account.go index 6730616..3bca661 100644 --- a/backend/api/internal/models/platfrom_account.go +++ b/backend/api/internal/models/platfrom_account.go @@ -4,13 +4,13 @@ import "git.apinb.com/bsm-sdk/core/database" // PlatfromAccount 对应 platfrom_account,表示平台总后台登录账号。 type PlatfromAccount struct { - Entity - Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名 - DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称 - Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 - PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值 - PlatformRoleCode string `gorm:"column:platform_role_code;type:varchar(64);not null;default:'root';index" json:"platform_role_code"` // 平台角色编码 - Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null;default:''" json:"phone"` // 手机号 + Entity // 公共实体字段 + Username string `gorm:"column:username;type:varchar(64);uniqueIndex;not null" json:"username"` // 登录用户名 + DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称 + Avatar string `gorm:"column:avatar;type:varchar(512);not null;default:''" json:"avatar"` // 头像资源地址 + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值 + PlatformRoleCode string `gorm:"column:platform_role_code;type:varchar(64);not null;index" json:"platform_role_code"` // 平台角色编码 + Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null;default:''" json:"phone"` // 手机号 } func init() { database.AppendMigrate(&PlatfromAccount{}) } diff --git a/backend/api/internal/models/report.go b/backend/api/internal/models/report.go index 9093e42..e01d178 100644 --- a/backend/api/internal/models/report.go +++ b/backend/api/internal/models/report.go @@ -7,11 +7,11 @@ import ( // Report 对应 report,保存统计报表档案。 type Report struct { - Entity - ReportCode string `gorm:"column:report_code;type:varchar(64);not null;uniqueIndex" json:"report_code"` - ReportType string `gorm:"column:report_type;type:varchar(32);not null" json:"report_type"` - StatPeriod string `gorm:"column:stat_period;type:varchar(64);not null" json:"stat_period"` - GeneratedAt time.Time `gorm:"column:generated_at;type:timestamptz;not null" json:"generated_at"` + Entity // 公共实体字段 + ReportCode string `gorm:"column:report_code;type:varchar(64);not null;uniqueIndex" json:"report_code"` // report_code 业务字段 + ReportType string `gorm:"column:report_type;type:varchar(32);not null" json:"report_type"` // report_type 业务字段 + StatPeriod string `gorm:"column:stat_period;type:varchar(64);not null" json:"stat_period"` // stat_period 业务字段 + GeneratedAt time.Time `gorm:"column:generated_at;type:timestamptz;not null" json:"generated_at"` // generated_at 业务字段 } func init() { database.AppendMigrate(&Report{}) } diff --git a/backend/api/internal/models/report_item.go b/backend/api/internal/models/report_item.go index a61b021..7aaa52e 100644 --- a/backend/api/internal/models/report_item.go +++ b/backend/api/internal/models/report_item.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // ReportItem 对应 report_item,保存报表维度明细。 type ReportItem struct { - Entity - ReportID uint64 `gorm:"column:report_id;not null;index" json:"report_id"` - Dimension string `gorm:"column:dimension;type:varchar(128);not null" json:"dimension"` - MetricCode string `gorm:"column:metric_code;type:varchar(64);not null" json:"metric_code"` - MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` + Entity // 公共实体字段 + ReportID uint64 `gorm:"column:report_id;not null;index" json:"report_id"` // report_id 业务字段 + Dimension string `gorm:"column:dimension;type:varchar(128);not null" json:"dimension"` // dimension 业务字段 + MetricCode string `gorm:"column:metric_code;type:varchar(64);not null" json:"metric_code"` // metric_code 业务字段 + MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` // metric_value 业务字段 } func init() { database.AppendMigrate(&ReportItem{}) } diff --git a/backend/api/internal/models/report_metric_snapshot.go b/backend/api/internal/models/report_metric_snapshot.go index 5e40d15..0871b53 100644 --- a/backend/api/internal/models/report_metric_snapshot.go +++ b/backend/api/internal/models/report_metric_snapshot.go @@ -7,12 +7,12 @@ import ( // ReportMetricSnapshot 对应 report_metric_snapshot,保存指标快照。 type ReportMetricSnapshot struct { - Entity - MetricCode string `gorm:"column:metric_code;type:varchar(64);not null;index" json:"metric_code"` - ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"` - ScopeID uint64 `gorm:"column:scope_id;not null;default:0;index" json:"scope_id"` - StatAt time.Time `gorm:"column:stat_at;type:timestamptz;not null;index" json:"stat_at"` - MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` + Entity // 公共实体字段 + MetricCode string `gorm:"column:metric_code;type:varchar(64);not null;index" json:"metric_code"` // metric_code 业务字段 + ScopeType string `gorm:"column:scope_type;type:varchar(32);not null" json:"scope_type"` // scope_type 业务字段 + ScopeID uint64 `gorm:"column:scope_id;not null;default:0;index" json:"scope_id"` // scope_id 业务字段 + StatAt time.Time `gorm:"column:stat_at;type:timestamptz;not null;index" json:"stat_at"` // stat_at 业务字段 + MetricValue string `gorm:"column:metric_value;type:varchar(128);not null" json:"metric_value"` // metric_value 业务字段 } func init() { database.AppendMigrate(&ReportMetricSnapshot{}) } diff --git a/backend/api/internal/models/saf_event.go b/backend/api/internal/models/saf_event.go index d051f72..eb9ba42 100644 --- a/backend/api/internal/models/saf_event.go +++ b/backend/api/internal/models/saf_event.go @@ -7,12 +7,12 @@ import ( // SafEvent 对应 saf_event,保存安全事件统一入口。 type SafEvent struct { - Entity - EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"` - Level int `gorm:"column:level;not null;default:3" json:"level"` - Title string `gorm:"column:title;type:varchar(256);not null;default:''" json:"title"` - SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;default:'';index" json:"smart_cylinder_valve_identity"` - SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"` + Entity // 公共实体字段 + EventCode string `gorm:"column:event_code;type:varchar(64);not null;uniqueIndex" json:"event_code"` // event_code 业务字段 + Level int `gorm:"column:level;not null;default:3" json:"level"` // level 业务字段 + Title string `gorm:"column:title;type:varchar(256);not null;default:''" json:"title"` // title 业务字段 + SmartCylinderValveIdentity string `gorm:"column:smart_cylinder_valve_identity;type:varchar(36);not null;default:'';index" json:"smart_cylinder_valve_identity"` // smart_cylinder_valve_identity 业务字段 + SLAAt *time.Time `gorm:"column:sla_at;type:timestamptz" json:"sla_at"` // sla_at 业务字段 } func init() { database.AppendMigrate(&SafEvent{}) } diff --git a/backend/api/internal/models/saf_event_disposal.go b/backend/api/internal/models/saf_event_disposal.go index 0657257..64281fc 100644 --- a/backend/api/internal/models/saf_event_disposal.go +++ b/backend/api/internal/models/saf_event_disposal.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // SafEventDisposal 对应 saf_event_disposal,保存安全处置记录。 type SafEventDisposal struct { - Entity - SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"` - Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` - Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` - OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` + Entity // 公共实体字段 + SafEventIdentity string `gorm:"column:saf_event_identity;type:varchar(36);not null;index" json:"saf_event_identity"` // saf_event_identity 业务字段 + Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段 + Reason string `gorm:"column:reason;type:text;not null;default:''" json:"reason"` // reason 业务字段 + OperatorIdentity string `gorm:"column:operator_identity;type:varchar(36);not null;default:''" json:"operator_identity"` // operator_identity 业务字段 } func init() { database.AppendMigrate(&SafEventDisposal{}) } diff --git a/backend/api/internal/models/saf_inspection.go b/backend/api/internal/models/saf_inspection.go index c278976..023c2a6 100644 --- a/backend/api/internal/models/saf_inspection.go +++ b/backend/api/internal/models/saf_inspection.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // SafInspection 对应 saf_inspection,保存安检与复检记录。 type SafInspection struct { - Entity - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` - Result string `gorm:"column:result;type:varchar(32);not null" json:"result"` - EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"` + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // staff_account_id 业务字段 + Result string `gorm:"column:result;type:varchar(32);not null" json:"result"` // result 业务字段 + EvidenceURI string `gorm:"column:evidence_uri;type:varchar(512);not null;default:''" json:"evidence_uri"` // evidence_uri 业务字段 } func init() { database.AppendMigrate(&SafInspection{}) } diff --git a/backend/api/internal/models/saf_rule.go b/backend/api/internal/models/saf_rule.go index 8dbc1c3..49ed75e 100644 --- a/backend/api/internal/models/saf_rule.go +++ b/backend/api/internal/models/saf_rule.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // SafRule 对应 saf_rule,保存安全规则。 type SafRule struct { - Entity - RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"` - VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` - Threshold string `gorm:"column:threshold;type:jsonb;not null;default:'{}'" json:"threshold"` - Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` - GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"` + Entity // 公共实体字段 + RuleCode string `gorm:"column:rule_code;type:varchar(64);not null;uniqueIndex" json:"rule_code"` // rule_code 业务字段 + VersionNo int `gorm:"column:version_no;not null;default:1" json:"version_no"` // version_no 业务字段 + Threshold string `gorm:"column:threshold;type:jsonb;not null;default:'{}'" json:"threshold"` // threshold 业务字段 + Action string `gorm:"column:action;type:varchar(64);not null" json:"action"` // action 业务字段 + GrayScope string `gorm:"column:gray_scope;type:jsonb;not null;default:'{}'" json:"gray_scope"` // gray_scope 业务字段 } func init() { database.AppendMigrate(&SafRule{}) } diff --git a/backend/api/internal/models/staff_account.go b/backend/api/internal/models/staff_account.go index 7e4a95c..dc1962d 100644 --- a/backend/api/internal/models/staff_account.go +++ b/backend/api/internal/models/staff_account.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // StaffAccount 对应 staff_account,是服务人员唯一的档案和 App 登录账户。 type StaffAccount struct { - Entity + Entity // 公共实体字段 Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 人员姓名 diff --git a/backend/api/internal/models/staff_credential.go b/backend/api/internal/models/staff_credential.go index c9e7c34..4c88e56 100644 --- a/backend/api/internal/models/staff_credential.go +++ b/backend/api/internal/models/staff_credential.go @@ -7,11 +7,11 @@ import ( // StaffCredential 对应 staff_credential,保存人员资质。 type StaffCredential struct { - Entity - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` - CredentialType string `gorm:"column:credential_type;type:varchar(64);not null" json:"credential_type"` - CredentialNo string `gorm:"column:credential_no;type:varchar(128);not null;default:''" json:"credential_no"` - ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` + Entity // 公共实体字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;index" json:"staff_account_id"` // staff_account_id 业务字段 + CredentialType string `gorm:"column:credential_type;type:varchar(64);not null" json:"credential_type"` // credential_type 业务字段 + CredentialNo string `gorm:"column:credential_no;type:varchar(128);not null;default:''" json:"credential_no"` // credential_no 业务字段 + ExpiredAt *time.Time `gorm:"column:expired_at;type:timestamptz" json:"expired_at"` // expired_at 业务字段 } func init() { database.AppendMigrate(&StaffCredential{}) } diff --git a/backend/api/internal/models/user_account.go b/backend/api/internal/models/user_account.go index df18795..c6107ec 100644 --- a/backend/api/internal/models/user_account.go +++ b/backend/api/internal/models/user_account.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // UserAccount 对应 user_account,是业主客户唯一的档案和用户端登录账户。 type UserAccount struct { - Entity + Entity // 公共实体字段 Username string `gorm:"column:username;type:varchar(64);not null;uniqueIndex" json:"username"` // 登录名称 PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"` // 密码哈希 Name string `gorm:"column:name;type:varchar(64);not null" json:"name"` // 客户姓名 diff --git a/backend/api/internal/models/user_address.go b/backend/api/internal/models/user_address.go index d44b0da..c657a6e 100644 --- a/backend/api/internal/models/user_address.go +++ b/backend/api/internal/models/user_address.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // UserAddress 对应 user_address,保存用户地址。 type UserAddress struct { - Entity - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` - Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` - Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` - IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段 + Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段 + Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段 + IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` // is_default 业务字段 } func init() { database.AppendMigrate(&UserAddress{}) } diff --git a/backend/api/internal/models/user_service_relation.go b/backend/api/internal/models/user_service_relation.go index e441002..581fac1 100644 --- a/backend/api/internal/models/user_service_relation.go +++ b/backend/api/internal/models/user_service_relation.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // UserServiceRelation 对应 user_service_relation,保存用户服务归属快照。 type UserServiceRelation struct { - Entity - UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` - GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` - DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` - StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` + Entity // 公共实体字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index" json:"user_account_id"` // user_account_id 业务字段 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;default:0;index" json:"gas_basic_id"` // gas_basic_id 业务字段 + DeliveryBasicID uint64 `gorm:"column:delivery_basic_id;not null;default:0;index" json:"delivery_basic_id"` // delivery_basic_id 业务字段 + StaffAccountID uint64 `gorm:"column:staff_account_id;not null;default:0;index" json:"staff_account_id"` // staff_account_id 业务字段 } func init() { database.AppendMigrate(&UserServiceRelation{}) } diff --git a/backend/api/internal/models/wallet.go b/backend/api/internal/models/wallet.go index 0036231..3e61929 100644 --- a/backend/api/internal/models/wallet.go +++ b/backend/api/internal/models/wallet.go @@ -4,11 +4,11 @@ import "git.apinb.com/bsm-sdk/core/database" // Wallet 对应 wallet,保存余额账户。 type Wallet struct { - Entity - OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"` - OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"` - BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"` - FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"` + Entity // 公共实体字段 + OwnerType string `gorm:"column:owner_type;type:varchar(32);not null" json:"owner_type"` // owner_type 业务字段 + OwnerID uint64 `gorm:"column:owner_id;not null;index" json:"owner_id"` // owner_id 业务字段 + BalanceAmount int64 `gorm:"column:balance_amount;not null;default:0" json:"balance_amount"` // balance_amount 业务字段 + FrozenAmount int64 `gorm:"column:frozen_amount;not null;default:0" json:"frozen_amount"` // frozen_amount 业务字段 } func init() { database.AppendMigrate(&Wallet{}) } diff --git a/backend/api/internal/models/wallet_ledger.go b/backend/api/internal/models/wallet_ledger.go index 8b54d88..432c644 100644 --- a/backend/api/internal/models/wallet_ledger.go +++ b/backend/api/internal/models/wallet_ledger.go @@ -4,12 +4,12 @@ import "git.apinb.com/bsm-sdk/core/database" // WalletLedger 对应 wallet_ledger,保存不可变资金流水。 type WalletLedger struct { - Entity - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` - Amount int64 `gorm:"column:amount;not null" json:"amount"` - Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"` - BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"` - ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"` + Entity // 公共实体字段 + WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 + Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 + Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"` // direction 业务字段 + BalanceAfter int64 `gorm:"column:balance_after;not null" json:"balance_after"` // balance_after 业务字段 + ReferenceIdentity string `gorm:"column:reference_identity;type:varchar(36);not null;default:'';index" json:"reference_identity"` // reference_identity 业务字段 } func init() { database.AppendMigrate(&WalletLedger{}) } diff --git a/backend/api/internal/models/wallet_recharge.go b/backend/api/internal/models/wallet_recharge.go index 46aa000..0144b97 100644 --- a/backend/api/internal/models/wallet_recharge.go +++ b/backend/api/internal/models/wallet_recharge.go @@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database" // WalletRecharge 对应 wallet_recharge,保存充值记录。 type WalletRecharge struct { - Entity - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` - Amount int64 `gorm:"column:amount;not null" json:"amount"` - Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` + Entity // 公共实体字段 + WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 + Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 + Channel string `gorm:"column:channel;type:varchar(32);not null" json:"channel"` // channel 业务字段 } func init() { database.AppendMigrate(&WalletRecharge{}) } diff --git a/backend/api/internal/models/wallet_withdrawal.go b/backend/api/internal/models/wallet_withdrawal.go index 68204a9..9337159 100644 --- a/backend/api/internal/models/wallet_withdrawal.go +++ b/backend/api/internal/models/wallet_withdrawal.go @@ -4,10 +4,10 @@ import "git.apinb.com/bsm-sdk/core/database" // WalletWithdrawal 对应 wallet_withdrawal,保存提现记录。 type WalletWithdrawal struct { - Entity - WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` - Amount int64 `gorm:"column:amount;not null" json:"amount"` - BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"` + Entity // 公共实体字段 + WalletID uint64 `gorm:"column:wallet_id;not null;index" json:"wallet_id"` // wallet_id 业务字段 + Amount int64 `gorm:"column:amount;not null" json:"amount"` // amount 业务字段 + BankAccountMasked string `gorm:"column:bank_account_masked;type:varchar(128);not null;default:''" json:"bank_account_masked"` // bank_account_masked 业务字段 } func init() { database.AppendMigrate(&WalletWithdrawal{}) } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 18f9a54..1576003 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -18,6 +18,7 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { protected := engine.Group(basePath) protected.Use(middleware.JwtAuth(true)) + protected.Use(platform.RequirePlatformMenuAccess()) protected.GET("/auth/profile", platform.CurrentProfile) protected.PUT("/auth/password", platform.ChangePassword) protected.GET("/dashboard/overview", platform.DashboardOverview) @@ -26,6 +27,12 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeliveryRoute(protected) registerStaffRoute(protected) registerUserRoute(protected) + registerDeviceRoute(protected) + registerSafetyRoute(protected) + registerCommerceRoute(protected) + registerFinanceRoute(protected) + registerContentRoute(protected) + registerAuditRoute(protected) registerPlatformRoute(protected) } @@ -37,6 +44,44 @@ 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{}, 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{}) + registerReadOnlyResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}) +} + +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{"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) + 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{"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) +} + +func registerCommerceRoute(group *gin.RouterGroup) { + 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{})) + 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_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) { @@ -59,8 +104,44 @@ func registerPlatformRoute(group *gin.RouterGroup) { role.PUT("/:identity", platform.UpdatePlatformRole) role.PATCH("/:identity/status", platform.UpdatePlatformRoleStatus) role.DELETE("/:identity", platform.ArchivePlatformRole) + role.GET("/:identity/menu", platform.ListPlatformRoleMenuIdentities) + role.PUT("/:identity/menu", platform.ReplacePlatformRoleMenus) role.PUT("/:identity/menus", platform.ReplacePlatformRoleMenus) - registerWritableResource(group, "/platform/platform_menu", platform.ListPlatformMenu, platform.CreatePlatformMenu, platform.GetPlatformMenu, platform.UpdatePlatformMenu, &models.PlatformMenu{}) + menu := group.Group("/platform/platform_menu") + menu.GET("", platform.ListPlatformMenu) + menu.POST("", platform.CreatePlatformMenu) + menu.GET("/:identity", platform.GetPlatformMenu) + menu.PUT("/:identity", platform.UpdatePlatformMenu) + menu.PATCH("/:identity/status", platform.UpdatePlatformMenuStatus) + menu.DELETE("/:identity", platform.ArchivePlatformMenu) +} + +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{})) + 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{}) + 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) { @@ -72,3 +153,23 @@ 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, relations ...platform.ResourceRelation) { + list, create, get, update := platform.ResourceHandlers(model, fields, fields, relations...) + 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} +} + +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 331f70c..078edf4 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -4,9 +4,39 @@ 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) + 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.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) + assertRouteMethods(t, routes, path+"/:identity/status", http.MethodPatch) + } + } +} + func TestPlatformGasRouteUsesGasBasic(t *testing.T) { engine := gin.New() RegisterPlatform("heqi", engine) @@ -53,9 +83,95 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) } + assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menu", http.MethodGet, http.MethodPut) 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_order_item", "/ec/ec_review", + "/delivery/delivery_task", "/delivery/delivery_track", + } { + 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) + } + + trackPoint := "/heqi/platform/v1/delivery/delivery_track_point" + assertRouteMethods(t, routes, trackPoint, http.MethodGet) + assertRouteMethods(t, routes, trackPoint+"/:identity", http.MethodGet) + assertNoRouteMethods(t, routes, trackPoint, http.MethodPost) + assertNoRouteMethods(t, routes, trackPoint+"/:identity", http.MethodPut, http.MethodDelete) + assertNoRouteMethods(t, routes, trackPoint+"/: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 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 { @@ -64,3 +180,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/superpowers/plans/2026-07-27-platform-admin-final-important-fixes.md b/docs/superpowers/plans/2026-07-27-platform-admin-final-important-fixes.md new file mode 100644 index 0000000..687ec77 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-platform-admin-final-important-fixes.md @@ -0,0 +1,73 @@ +# Platform Admin Final Important Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 修复平台总后台终审的 8 项 Important 问题,并以回归测试、全量校验和提交记录证明修复结果。 + +**Architecture:** 后端统一在资源边界完成响应投影、关键字查询和精确位置授权,避免各页面或处理器自行绕过安全规则。前端以类型化字段定义驱动表单控件和请求载荷,并为审批与树归档提供明确交互;模型注释通过 AST 审计守住完整性。 + +**Tech Stack:** Go 1.26、Gin、GORM、Vue 3、TypeScript、Arco Design Vue、Node test runner、Biome 2.5、pnpm。 + +## Global Constraints + +- HTTP 仅公开 `identity` 和 `<实体>_identity`,禁止公开数据库自增 ID。 +- 精确轨迹坐标必须由显式短期授权声明控制,普通列表和详情必须脱敏。 +- 可选关系为空时不进入请求体;数字、布尔、时间和 JSON 字段保持正确类型。 +- 归档为 `status=archived`,不得物理删除。 +- 所有模型字段必须有中文注释。 + +--- + +### Task 1: 后端安全边界与筛选 + +**Files:** +- Modify: `backend/api/internal/logic/platform/task4_resources.go` +- Modify: `backend/api/internal/logic/platform/platform.go` +- Modify: `backend/api/internal/logic/platform/resource.go` +- Modify: `backend/api/internal/routers/platform.go` +- Test: `backend/api/internal/logic/platform/resource_test.go` +- Test: `backend/api/internal/routers/platform_test.go` + +**Interfaces:** +- Consumes: Gin 查询参数、JWT `location_scope`、资源关系定义。 +- Produces: 创建响应公共投影、轨迹点脱敏处理器、通用 `keyword` 过滤。 + +- [ ] **Step 1: 写创建响应、轨迹点授权和关键字筛选失败测试。** +- [ ] **Step 2: 运行定向 Go 测试并确认按预期失败。** +- [ ] **Step 3: 统一创建响应投影,轨迹点改为只读授权处理器,并把安全关键字条件同时用于 count/list。** +- [ ] **Step 4: 运行定向 Go 测试并确认通过。** + +### Task 2: 前端类型化表单、审批和树归档 + +**Files:** +- Modify: `frontend/platform_admin/src/api/resources.ts` +- Create: `frontend/platform_admin/src/api/resource-form.ts` +- Modify: `frontend/platform_admin/src/views/shared/CrudListPage.vue` +- Modify: `frontend/platform_admin/src/views/shared/ReadOnlyListPage.vue` +- Modify: `frontend/platform_admin/src/views/shared/TreePage.vue` +- Test: `frontend/platform_admin/scripts/final-important.test.mjs` + +**Interfaces:** +- Consumes: `ResourceField{type, required}` 与当前表单值。 +- Produces: `buildResourcePayload(fields, form)`,以及审批 POST 和树归档交互。 + +- [ ] **Step 1: 写字段类型、空可选关系、审批动作和树归档失败测试。** +- [ ] **Step 2: 运行 Node 测试并确认按预期失败。** +- [ ] **Step 3: 实现类型化控件/载荷、审批详情动作和树归档确认。** +- [ ] **Step 4: 运行 Node 测试并确认通过。** + +### Task 3: Biome 与模型中文注释 + +**Files:** +- Modify: `frontend/platform_admin/biome.json` +- Modify: `backend/api/internal/models/*.go` +- Test: `backend/api/internal/models/comments_test.go` + +**Interfaces:** +- Consumes: Biome 配置与 Go AST。 +- Produces: 不依赖缺失本地 ignore 文件的 lint 配置,以及每个导出模型字段的中文注释。 + +- [ ] **Step 1: 复现 Biome ignore-file 配置错误并写模型注释 AST 失败测试。** +- [ ] **Step 2: 修正 Biome VCS ignore 配置,为全部模型字段补中文注释。** +- [ ] **Step 3: 运行 `go test ./...`、Node 测试、`pnpm audit:platform`、`pnpm lint`、`pnpm type:check` 和 `pnpm build`。** +- [ ] **Step 4: 复核差异并提交。** diff --git a/docs/平台总后台审计报告-2026-07-27.md b/docs/平台总后台审计报告-2026-07-27.md new file mode 100644 index 0000000..6e4e11c --- /dev/null +++ b/docs/平台总后台审计报告-2026-07-27.md @@ -0,0 +1,74 @@ +# 平台总后台审计报告(2026-07-27) + +## 范围与结论 + +本次审计覆盖 46 个平台后台资源:33 个可写资源、12 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing ` 失败。 + +终审追加识别的 8 项 Important 问题已全部处置并纳入回归验证:创建响应安全投影、轨迹点精确位置授权、关键字筛选一致性、表单字段类型、空可选关系、审批动作、树节点归档,以及 Biome/模型中文注释完整性。终审后两轮复核又关闭 12 项 Important:JSONB 字符串绑定、日期 RFC3339 边界、编辑密码语义、创建响应与关键字查询的显式安全白名单、服务端角色菜单授权、平台账户非 root 角色约束、默认 PII/坐标响应投影、角色菜单分配 UI,以及管理权限升级、证件附件响应、空菜单撤权和缺失菜单种子。 + +## 发现与处置 + +| 发现 | 处置 | 验证 | +| --- | --- | --- | +| 只读页面的静态检查未覆盖状态变更调用 | 将 `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` | +| 仅追加处置和只读状态写入存在审计盲区 | 处置资源必须由 `saf_event` 详情动作触发、不可拥有独立页面;后端仅允许 GET/POST,且按事件标识查询处置历史;只读资源扫描 API、路由和页面中的 `updateStatus` | 6 个 `audit-check.test.mjs` 用例及逐契约路由方法测试 | +| 创建接口可能回传内部关系 ID、密码散列、地址或其他未枚举字段 | 创建成功统一经过公共身份解析和显式安全白名单,只保留 `identity`、关联 `*_identity`、状态、版本及时间元数据;地址和其他业务字段默认不返回 | `TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting`、`TestCreatedResourceResponseUsesSafeAllowlist` | +| 轨迹点资源可写,且普通列表/详情可能泄露精确经纬度 | `delivery_track_point` 改为只读契约;仅 JWT 明确声明 `location_scope=precise` 时返回精确坐标,其他响应移除坐标 | `TestListDeliveryTrackPointsMasksCoordinatesWithoutPreciseLocationScope`、`TestGetDeliveryTrackPointReturnsCoordinatesWithPreciseLocationScope` | +| 受保护平台路由仅验证 JWT,非 root 账号可能访问未分配业务域,菜单接口也返回全部菜单 | JWT 后增加服务端角色菜单授权;root 放行全部业务域,非 root 仅可访问启用角色已分配菜单对应的业务域;profile 返回真实角色和菜单码,前端路由不再使用 `roles: ['*']` | `TestPlatformMenuAllowsOnlyAssignedDomain`、`TestListPlatformMenuReturnsOnlyMenusAssignedToNonRootRole`、`final-important.test.mjs` 的路由权限用例 | +| 创建平台账户未指定角色时默认成为 root,角色变更也可写入 root | 移除模型的 root 数据库默认值;创建账户强制显式指定启用的非系统角色;创建与更新都拒绝 root、缺失、停用或系统角色;前端只提供可分配角色选择 | `TestCreatePlatformAccountRequiresAssignableNonRootRole`、`pnpm type:check` | +| 通用列表与详情仅投影关联 identity,姓名、电话、头像、地址和非轨迹资源坐标可能原样返回 | 所有通用 list/detail 出口统一执行响应投影:电话和个人姓名脱敏,移除头像与地址;除显式 `location_scope=precise` 外移除所有经纬度,显式坐标授权也不放宽 PII | `TestDefaultResourceResponseMasksPIIAndCoordinates`、`TestExplicitPreciseScopeRetainsCoordinatesButStillMasksPII` | +| 角色菜单替换只有非规范复数 URL,前端没有可用入口 | 增加规范 `GET/PUT /platform/platform_role/:identity/menu`,保留原复数 PUT 兼容入口;角色详情可读取已选菜单、多选菜单 identity 并原子替换 | `TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD`、`final-important.test.mjs` 的角色菜单 UI 用例 | +| 拥有 `platform` 菜单的非 root 角色可继续创建角色、菜单或改写授权,形成权限升级 | 角色与菜单写操作、角色菜单读取/替换,以及平台账户角色创建/改派均增加 root-only handler 边界;菜单状态和归档使用专用受保护 handler | `TestNonRootCannotManagePlatformRolesOrMenus`、`TestNonRootCannotAssignPlatformAccountRole` | +| 通用投影未覆盖证件编号、检查凭证和附件地址,平台账户自定义 list/detail 仍返回显示名和头像 | 集中维护敏感响应键,移除 `credential_no`、证据、文件、附件和证件 URI;平台账户自定义响应复用同一投影,显示名脱敏、头像移除,精确定位 scope 不放宽 PII | `TestPublicResponseProjectionRemovesCredentialAndAttachmentSecrets`、`TestPlatformAccountDetailMasksDisplayNameAndAvatarWithPreciseScope`、`TestPlatformAccountListMasksDisplayNameAndAvatar` | +| 角色菜单 UI 将 `menu_identities` 视为必填非空,无法撤销角色的全部菜单 | 仅该多选字段允许空数组并原样提交;其他必填字段规则不变,后端事务继续以空集合删除全部关系 | `TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally`、`final-important.test.mjs` 的空菜单撤权用例 | +| 初始化菜单缺少受保护前端域,非 root 角色无法被授予对应入口 | 幂等种子补齐 `device`、`safety`、`content`、`notification`、`customer_service`、`audit`,并继续为 root 建立关系 | `TestInitPlatformAccessSeedsEveryProtectedFrontendDomain` | +| 通用关键字只影响列表或使用 JSONB、身份及敏感文本字段,可能导致总数与结果不一致或扩大数据暴露面 | 仅查询显式允许的安全文本列,并按模型排除个人姓名等敏感列;同一条件同时应用于 count/list | `TestListGasAccountAppliesKeywordToCountAndRows`、`TestKeywordColumnsUseSafeTextAllowlist` | +| 前端表单缺少持久化类型边界,JSONB 字符串可能变成对象,日期不能绑定 Go `time.Time` | JSON 字段在前端校验后保留字符串,后端按模型 GORM 标签将对象/数组规范化为有效 JSON 字符串并拒绝非法文本;日期与时间统一提交 RFC3339 | `TestPrepareResourceValuesNormalizesStringJSONBFields`、`final-important.test.mjs` 的 JSON 与日期用例、`pnpm type:check` | +| 创建与编辑复用密码必填规则,编辑时可能要求或误提交密码 | 密码仅在创建模式必填并进入请求体;编辑表单隐藏密码字段,载荷边界也强制忽略密码 | `final-important.test.mjs` 的创建/编辑密码语义用例 | +| 空的可选关联标识可能作为空字符串进入请求体 | 载荷边界省略空的可选 `identity` 关系,同时保留必填校验 | `final-important.test.mjs` 的空可选关系用例 | +| 审批只读页缺少同意/驳回入口 | 资源定义声明详情动作,页面按审批身份提交动作及意见并刷新详情和列表 | `final-important.test.mjs` 的审批动作回归用例 | +| 树页面只有新增/编辑,没有符合软删除约束的归档操作 | 增加二次确认,并调用资源归档接口写入 `status=archived`,不执行物理删除 | `final-important.test.mjs` 的树归档回归用例 | +| Biome 依赖 worktree 中不存在的 ignore 文件,模型字段中文注释不完整 | 禁用错误的 VCS ignore 读取;新增 Go AST 测试,要求所有导出模型字段具备中文注释 | `TestEveryModelFieldHasChineseComment`、`pnpm lint` | + +## 身份字段与保留理由 + +- 对外 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 + +cd ../.. +node --test frontend/platform_admin/scripts/*.test.mjs + +cd frontend/platform_admin +pnpm lint +pnpm type:check +pnpm audit:platform +pnpm build +``` + +| 命令 | 退出码 | 结果 | +| --- | --- | --- | +| `go test ./...` | 0 | 通过 | +| `go build ./cmd/main` | 0 | 通过 | +| `node --test frontend/platform_admin/scripts/*.test.mjs` | 0 | 通过,15 个静态审计与终审回归用例;脚本按自身路径定位项目,不依赖当前工作目录 | +| `pnpm lint` | 0 | 通过;Biome 检查 169 个文件,无错误,保留 190 个非阻断 warning 和 12 个 info | +| `pnpm type:check` | 0 | 通过 | +| `pnpm audit:platform` | 0 | 通过 | +| `pnpm build` | 0 | 通过 | diff --git a/frontend/platform_admin/biome.json b/frontend/platform_admin/biome.json index de78d06..dfade39 100644 --- a/frontend/platform_admin/biome.json +++ b/frontend/platform_admin/biome.json @@ -3,7 +3,7 @@ "vcs": { "enabled": true, "clientKind": "git", - "useIgnoreFile": true + "useIgnoreFile": false }, "files": { "ignoreUnknown": false, diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index afd6f91..7f7fe49 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -12,8 +12,8 @@ "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 .", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", "format": "biome format --write ." }, "dependencies": { diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 4050e7d..6ea3de1 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,90 +1,125 @@ +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 baseline = process.argv.includes('--baseline'); +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 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]); -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 requiredBackendRoutes(contract) { + 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 }]; + 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 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); - if (entry.isDirectory() && entry.name !== 'node_modules') { - walk(full, acc); - } else if (/\.(ts|vue)$/.test(entry.name)) { - acc.push(full); +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 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; + hasPage = true; + if (/locale:\s*['\"]menu\.platform\./.test(match[2] ?? '')) hasMenu = true; } } - return acc; + return { hasPage, hasMenu }; } -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); - } +function sourceEntries(sources, fallbackDirectory) { + if (sources instanceof Map) return [...sources]; + return sources.map((source, index) => [`${fallbackDirectory}/${index}`, source]); } -let zhOk = 0; -for (const file of zhLocales) { - if (/[\u4e00-\u9fff]/.test(fs.readFileSync(file, 'utf8'))) zhOk += 1; +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -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; +/** 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/)) { + // 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(scanned)) failures.push(`${file}: internal identifier id`); } } + return failures; } -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; +/** 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') { + 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); + 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', ['.ts']), + 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..6cdfdb2 --- /dev/null +++ b/frontend/platform_admin/scripts/audit-check.test.mjs @@ -0,0 +1,94 @@ +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('防护表达式不能掩盖同一行的内部 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: [ + { 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']); +}); + +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', '