Merge branch 'codex/platform-admin-full-audit'
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
45
backend/api/cmd/resource-contract/main.go
Normal file
45
backend/api/cmd/resource-contract/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
51
backend/api/internal/initdb/platform_test.go
Normal file
51
backend/api/internal/initdb/platform_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
91
backend/api/internal/logic/platform/access.go
Normal file
91
backend/api/internal/logic/platform/access.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
103
backend/api/internal/logic/platform/audit.go
Normal file
103
backend/api/internal/logic/platform/audit.go
Normal file
@@ -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))
|
||||
}
|
||||
135
backend/api/internal/logic/platform/audit_test.go
Normal file
135
backend/api/internal/logic/platform/audit_test.go
Normal file
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func CreateGasBasic(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, request)
|
||||
respondCreatedResource(ctx, request)
|
||||
}
|
||||
|
||||
// UpdateGasBasic 更新可燃气体站基础资料。
|
||||
|
||||
31
backend/api/internal/logic/platform/health_test.go
Normal file
31
backend/api/internal/logic/platform/health_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
748
backend/api/internal/logic/platform/task4_resources.go
Normal file
748
backend/api/internal/logic/platform/task4_resources.go
Normal file
@@ -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])
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func CreateUser(ctx *gin.Context) {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, user)
|
||||
respondCreatedResource(ctx, user)
|
||||
}
|
||||
|
||||
// UpdateUser 更新业主客户档案。
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
53
backend/api/internal/models/comments_test.go
Normal file
53
backend/api/internal/models/comments_test.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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"` // 配送点名称
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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"` // 展示名称
|
||||
|
||||
@@ -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"` // 统一社会信用代码
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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"` // 菜单名称
|
||||
|
||||
@@ -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"` // 数据权限范围
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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"` // 人员姓名
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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"` // 客户姓名
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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{}) }
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: 复核差异并提交。**
|
||||
74
docs/平台总后台审计报告-2026-07-27.md
Normal file
74
docs/平台总后台审计报告-2026-07-27.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 平台总后台审计报告(2026-07-27)
|
||||
|
||||
## 范围与结论
|
||||
|
||||
本次审计覆盖 46 个平台后台资源:33 个可写资源、12 个只读资源和 1 个仅追加的安全事件处置资源。后端资源契约现同时声明领域、资源名、HTTP 路径、页面类型和读写模式,并由运行时注册路由生成清单。前端审计据此逐项校验后端路由、前端资源声明、实际加载的页面组件和带菜单元数据的路由;任一层缺失均以 `领域/资源: missing <layer>` 失败。
|
||||
|
||||
终审追加识别的 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 | 通过 |
|
||||
@@ -3,7 +3,7 @@
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
94
frontend/platform_admin/scripts/audit-check.test.mjs
Normal file
94
frontend/platform_admin/scripts/audit-check.test.mjs
Normal file
@@ -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: '<script setup>resourceApi.updateStatus(resource, identity, status)</script>',
|
||||
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', '<a-table-column data-index="id" />'],
|
||||
]));
|
||||
|
||||
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', '<template />']]),
|
||||
apiSources: new Map(),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, [
|
||||
'safety/saf_event_disposal: missing saf_event detail action',
|
||||
'safety/saf_event_disposal: independent page exposed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('只读资源拒绝 API 或路由中的状态写入', () => {
|
||||
const failures = auditPlatform({
|
||||
manifest: { resources: [{ domain: 'wallet', name: 'wallet', path: '/wallet/wallet', mode: 'readonly', pageKind: 'list' }], routes: [
|
||||
{ method: 'GET', path: '/wallet/wallet' }, { method: 'GET', path: '/wallet/wallet/:identity' },
|
||||
] },
|
||||
resources: [{ name: 'wallet', resource: '/wallet/wallet', mode: 'readonly', pageKind: 'list', title: '钱包', fields: [{ key: 'balance_amount', label: '余额' }] }],
|
||||
readOnlyPage: '',
|
||||
routeSources: ["{ component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet' } }"],
|
||||
viewSources: new Map([['src/views/wallet/wallet/ListPage.vue', "getResource('/wallet/wallet')"]]),
|
||||
apiSources: new Map([['src/api/wallet.ts', "resourceApi.updateStatus('/wallet/wallet', identity, 'disabled')"]]),
|
||||
});
|
||||
|
||||
assert.deepEqual(failures, ['wallet/wallet: readonly status mutation in src/api/wallet.ts']);
|
||||
});
|
||||
192
frontend/platform_admin/scripts/final-important.test.mjs
Normal file
192
frontend/platform_admin/scripts/final-important.test.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const fromProjectRoot = (...segments) => path.join(projectRoot, ...segments);
|
||||
|
||||
function loadResources() {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(fromProjectRoot('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 loadResourceForm() {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/api/resource-form.ts'), 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const resourceModule = { exports: {} };
|
||||
vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports });
|
||||
return resourceModule.exports;
|
||||
}
|
||||
|
||||
test('资源字段声明保留数字、布尔、时间与 JSON 类型', () => {
|
||||
const resources = loadResources();
|
||||
const field = (resource, key) => resources.find((item) => item.name === resource).fields.find((item) => item.key === key);
|
||||
assert.equal(field('ec_product', 'price_amount').type, 'number');
|
||||
assert.equal(field('ec_product_image', 'is_cover').type, 'boolean');
|
||||
assert.equal(field('delivery_track', 'started_at').type, 'datetime');
|
||||
assert.equal(field('dev_telemetry', 'payload').type, 'json');
|
||||
});
|
||||
|
||||
test('表单载荷构造器省略空的可选关系并转换字段类型', async () => {
|
||||
const resourceFormPath = fromProjectRoot('src/api/resource-form.ts');
|
||||
assert.ok(fs.existsSync(resourceFormPath), 'resource-form.ts should define the payload boundary');
|
||||
const resourceForm = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'gas_basic_identity', label: '气站', type: 'identity' },
|
||||
{ key: 'quantity', label: '数量', type: 'number' },
|
||||
{ key: 'selected', label: '选中', type: 'boolean' },
|
||||
];
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(resourceForm.buildResourcePayload(fields, {
|
||||
gas_basic_identity: '',
|
||||
quantity: '2',
|
||||
selected: false,
|
||||
}))),
|
||||
{ quantity: 2, selected: false },
|
||||
);
|
||||
});
|
||||
|
||||
test('安全规则与订单明细将 JSON 字段作为有效 JSON 字符串提交', () => {
|
||||
const resources = loadResources();
|
||||
const { buildResourcePayload } = loadResourceForm();
|
||||
const fields = (name) => resources.find((item) => item.name === name).fields;
|
||||
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('saf_rule'), {
|
||||
rule_code: 'pressure-limit',
|
||||
threshold: '{"max":10}',
|
||||
action: 'close-valve',
|
||||
gray_scope: '["north"]',
|
||||
}))),
|
||||
{
|
||||
rule_code: 'pressure-limit',
|
||||
threshold: '{"max":10}',
|
||||
action: 'close-valve',
|
||||
gray_scope: '["north"]',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields('ec_order_item'), {
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: '{"name":"液化气"}',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
}))),
|
||||
{
|
||||
ec_order_identity: 'order-a',
|
||||
ec_product_identity: 'product-a',
|
||||
product_snapshot: '{"name":"液化气"}',
|
||||
quantity: 2,
|
||||
sale_amount: 500,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('日期按 RFC3339 提交,密码只在创建时必填并提交', () => {
|
||||
const { buildResourcePayload, isResourceFieldRequired } = loadResourceForm();
|
||||
const fields = [
|
||||
{ key: 'bill_date', label: '账单日期', type: 'date', required: true },
|
||||
{ key: 'started_at', label: '开始时间', type: 'datetime' },
|
||||
{ key: 'username', label: '用户名', type: 'text', required: true },
|
||||
{ key: 'password', label: '密码', type: 'password', required: true },
|
||||
];
|
||||
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'create'), true);
|
||||
assert.equal(isResourceFieldRequired(fields[3], 'edit'), false);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
started_at: '2026-07-27T10:30:00Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
}, 'create'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
started_at: '2026-07-27T10:30:00.000Z',
|
||||
username: 'operator',
|
||||
password: 'secret',
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload(fields, {
|
||||
bill_date: '2026-07-27',
|
||||
username: 'operator',
|
||||
password: 'replacement-must-not-be-sent',
|
||||
}, 'edit'))),
|
||||
{
|
||||
bill_date: '2026-07-27T00:00:00.000Z',
|
||||
username: 'operator',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('审批只读页提供同意和驳回操作', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/ReadOnlyListPage.vue'), 'utf8');
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
assert.match(resources, /aud_approval[\s\S]*\/audit\/aud_approval\/:identity\/approve/);
|
||||
assert.match(source, /submitDetailAction/);
|
||||
});
|
||||
|
||||
test('树页面通过资源归档接口归档节点', () => {
|
||||
const source = fs.readFileSync(fromProjectRoot('src/views/shared/TreePage.vue'), 'utf8');
|
||||
assert.match(source, /resourceApi\.archive/);
|
||||
assert.match(source, /Modal\.(warning|confirm)/);
|
||||
});
|
||||
|
||||
test('route access uses authenticated role and assigned menu codes', () => {
|
||||
const routeDir = fromProjectRoot('src/router/routes/modules');
|
||||
const routeSource = fs.readdirSync(routeDir)
|
||||
.filter((name) => name.endsWith('.ts'))
|
||||
.map((name) => fs.readFileSync(path.join(routeDir, name), 'utf8'))
|
||||
.join('\n');
|
||||
const userStore = fs.readFileSync(fromProjectRoot('src/store/modules/user/index.ts'), 'utf8');
|
||||
const permission = fs.readFileSync(fromProjectRoot('src/hooks/permission.ts'), 'utf8');
|
||||
|
||||
assert.doesNotMatch(routeSource, /roles:\s*\[\s*['"]\*['"]\s*\]/);
|
||||
assert.match(routeSource, /menuCode:\s*['"]finance['"]/);
|
||||
assert.match(userStore, /profile\.role_code/);
|
||||
assert.match(userStore, /menuCodes/);
|
||||
assert.match(permission, /menuCode/);
|
||||
});
|
||||
|
||||
test('platform role UI replaces assigned menu identities through the singular contract URL', () => {
|
||||
const resources = fs.readFileSync(fromProjectRoot('src/api/resources.ts'), 'utf8');
|
||||
const crudPage = fs.readFileSync(fromProjectRoot('src/views/shared/CrudListPage.vue'), 'utf8');
|
||||
const platformApi = fs.readFileSync(fromProjectRoot('src/api/platform.ts'), 'utf8');
|
||||
|
||||
assert.match(resources, /platform_role[\s\S]*\/platform\/platform_role\/:identity\/menu/);
|
||||
assert.match(resources, /menu-identities/);
|
||||
assert.match(crudPage, /multiple/);
|
||||
assert.match(crudPage, /menu_identities/);
|
||||
assert.match(platformApi, /\/platform\/platform_role\/\$\{identity\}\/menu/);
|
||||
assert.match(platformApi, /method:\s*['"]PUT['"]/);
|
||||
});
|
||||
|
||||
test('platform role UI permits an empty menu selection to revoke every assignment', () => {
|
||||
const resources = loadResources();
|
||||
const { buildResourcePayload, isMissingField } = loadResourceForm();
|
||||
const role = resources.find((item) => item.name === 'platform_role');
|
||||
const menuField = role.detailActions
|
||||
.flatMap((action) => action.fields)
|
||||
.find((field) => field.key === 'menu_identities');
|
||||
|
||||
assert.equal(menuField.required, undefined);
|
||||
assert.equal(isMissingField([]), true);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(buildResourcePayload([menuField], {
|
||||
menu_identities: [],
|
||||
}))),
|
||||
{ menu_identities: [] },
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { request } from './http';
|
||||
/** 平台登录和当前账号资料的接口模型。 */
|
||||
export type LoginData = { username: string; password: string };
|
||||
export type LoginReply = { access_token: string; token_type: string; identity: string; display_name: string; role_code: string };
|
||||
export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string };
|
||||
export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string; menu_codes: string[] };
|
||||
|
||||
export const authApi = {
|
||||
login: (data: LoginData) => request<LoginReply>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resourceApi } from './resource';
|
||||
|
||||
/** 平台角色、菜单、账号和工作台接口。 */
|
||||
export type PlatformRole = { identity: string; role_code: string; name: string; data_scope: string; is_system: boolean; status: string };
|
||||
export type PlatformMenu = { id: number; identity: string; parent_id: number; menu_code: string; name: string; icon: string; path: string; sort_no: number };
|
||||
export type PlatformMenu = { identity: string; parent_identity?: string; menu_code: string; name: string; icon: string; path: string; sort_no: number };
|
||||
|
||||
export const platformApi = {
|
||||
overview: () => request<Record<string, number>>('/dashboard/overview'),
|
||||
@@ -11,4 +11,16 @@ export const platformApi = {
|
||||
listRole: () => resourceApi.list<PlatformRole>('/platform/platform_role'),
|
||||
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform/platform_role', data),
|
||||
listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/platform/platform_menu'),
|
||||
listRoleMenuIdentities: (identity: string) =>
|
||||
request<{ menu_identities: string[] }>(
|
||||
`/platform/platform_role/${identity}/menu`,
|
||||
),
|
||||
replaceRoleMenus: (identity: string, menuIdentities: string[]) =>
|
||||
request<{ updated: boolean }>(
|
||||
`/platform/platform_role/${identity}/menu`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ menu_identities: menuIdentities }),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
65
frontend/platform_admin/src/api/resource-form.ts
Normal file
65
frontend/platform_admin/src/api/resource-form.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ResourceField } from './resources';
|
||||
|
||||
export type ResourceFormValue = string | number | boolean | string[] | undefined;
|
||||
export type ResourceFormMode = 'create' | 'edit';
|
||||
|
||||
export function isMissingField(value: ResourceFormValue | null): boolean {
|
||||
return value === '' || value === null || value === undefined || (Array.isArray(value) && value.length === 0);
|
||||
}
|
||||
|
||||
export function isResourceFieldRequired(
|
||||
field: ResourceField,
|
||||
mode: ResourceFormMode,
|
||||
): boolean {
|
||||
return Boolean(field.required && !(mode === 'edit' && field.type === 'password'));
|
||||
}
|
||||
|
||||
export function buildResourcePayload(
|
||||
fields: ResourceField[],
|
||||
form: Record<string, ResourceFormValue>,
|
||||
mode: ResourceFormMode = 'create',
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if (mode === 'edit' && field.type === 'password') continue;
|
||||
const value = form[field.key];
|
||||
if (field.type === 'menu-identities' && Array.isArray(value)) {
|
||||
payload[field.key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isMissingField(value)) {
|
||||
if (!field.required) continue;
|
||||
payload[field.key] = value;
|
||||
continue;
|
||||
}
|
||||
switch (field.type) {
|
||||
case 'number': {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number))
|
||||
throw new Error(`${field.label}必须是数字`);
|
||||
payload[field.key] = number;
|
||||
break;
|
||||
}
|
||||
case 'boolean':
|
||||
payload[field.key] = value === true || value === 'true';
|
||||
break;
|
||||
case 'json':
|
||||
if (typeof value !== 'string')
|
||||
throw new Error(`${field.label}必须是有效 JSON`);
|
||||
JSON.parse(value);
|
||||
payload[field.key] = value;
|
||||
break;
|
||||
case 'date':
|
||||
case 'datetime': {
|
||||
const date = new Date(String(value));
|
||||
if (Number.isNaN(date.getTime()))
|
||||
throw new Error(`${field.label}必须是有效日期`);
|
||||
payload[field.key] = date.toISOString();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
payload[field.key] = value;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { request, type PageResult } from './http';
|
||||
|
||||
/** 所有标准 CRUD 资源共享的调用方法。 */
|
||||
export const resourceApi = {
|
||||
list: <T>(resource: string, page = 1, size = 20) => request<PageResult<T>>(`${resource}?page=${page}&size=${size}`),
|
||||
list: <T>(resource: string, page = 1, size = 20, filters: Record<string, string> = {}) => {
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size), ...filters });
|
||||
return request<PageResult<T>>(`${resource}?${query.toString()}`);
|
||||
},
|
||||
detail: <T>(resource: string, identity: string) => request<T>(`${resource}/${identity}`),
|
||||
create: <T>(resource: string, data: Record<string, unknown>) => request<T>(resource, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: <T>(resource: string, identity: string, data: Record<string, unknown>) => request<T>(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
660
frontend/platform_admin/src/api/resources.ts
Normal file
660
frontend/platform_admin/src/api/resources.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
export type ResourceMode = 'writable' | 'readonly' | 'append_only';
|
||||
export type ResourcePageKind = 'list' | 'tree';
|
||||
export type ResourceFieldType =
|
||||
| 'text'
|
||||
| 'password'
|
||||
| 'identity'
|
||||
| 'role-code'
|
||||
| 'menu-identities'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'json'
|
||||
| 'textarea';
|
||||
export type ResourceField = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: ResourceFieldType;
|
||||
required?: boolean;
|
||||
};
|
||||
export type DetailAction = {
|
||||
name: string;
|
||||
resource: string;
|
||||
fields: ResourceField[];
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
export type ResourceUiDefinition = {
|
||||
key: string;
|
||||
name: string;
|
||||
resource: string;
|
||||
title: string;
|
||||
mode: ResourceMode;
|
||||
pageKind: ResourcePageKind;
|
||||
fields: ResourceField[];
|
||||
requiredIdentities: string[];
|
||||
detailActions?: DetailAction[];
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
code: '编码',
|
||||
name: '名称',
|
||||
credit_code: '统一信用代码',
|
||||
principal: '负责人',
|
||||
address: '地址',
|
||||
longitude: '经度',
|
||||
latitude: '纬度',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
display_name: '显示名称',
|
||||
role_code: '角色编码',
|
||||
delivery_code: '配送编码',
|
||||
phone: '联系电话',
|
||||
avatar: '头像',
|
||||
work_status: '工作状态',
|
||||
credential_type: '资质类型',
|
||||
credential_no: '资质编号',
|
||||
expired_at: '到期时间',
|
||||
real_name: '实名姓名',
|
||||
is_default: '默认地址',
|
||||
device_no: '设备编号',
|
||||
model: '设备型号',
|
||||
online_status: '在线状态',
|
||||
effective_at: '生效时间',
|
||||
reported_at: '上报时间',
|
||||
payload: '遥测数据',
|
||||
rule_code: '规则编码',
|
||||
version_no: '版本号',
|
||||
threshold: '阈值',
|
||||
action: '处置动作',
|
||||
gray_scope: '灰度范围',
|
||||
event_code: '事件编码',
|
||||
level: '事件等级',
|
||||
title: '标题',
|
||||
sla_at: '处置时限',
|
||||
result: '检查结果',
|
||||
evidence_uri: '凭证地址',
|
||||
reason: '处置原因',
|
||||
sort_no: '排序号',
|
||||
product_code: '商品编码',
|
||||
price_amount: '售价',
|
||||
stock_quantity: '库存',
|
||||
value: '属性值',
|
||||
image_uri: '图片地址',
|
||||
is_cover: '封面图',
|
||||
quantity: '数量',
|
||||
selected: '是否选中',
|
||||
order_no: '订单号',
|
||||
total_amount: '订单金额',
|
||||
product_snapshot: '商品快照',
|
||||
sale_amount: '成交金额',
|
||||
score: '评分',
|
||||
channel: '渠道',
|
||||
amount: '金额',
|
||||
paid_at: '支付时间',
|
||||
settlement_no: '结算单号',
|
||||
subject_type: '结算对象类型',
|
||||
period_start: '结算开始时间',
|
||||
period_end: '结算结束时间',
|
||||
bill_date: '账单日期',
|
||||
difference_amount: '差异金额',
|
||||
content_type: '内容类型',
|
||||
body: '正文',
|
||||
publish_status: '发布状态',
|
||||
template_code: '模板编码',
|
||||
content: '内容',
|
||||
ticket_no: '工单号',
|
||||
category: '分类',
|
||||
priority: '优先级',
|
||||
platform_role_code: '平台角色',
|
||||
menu_identities: '菜单权限',
|
||||
data_scope: '数据范围',
|
||||
menu_code: '菜单编码',
|
||||
icon: '图标',
|
||||
path: '路径',
|
||||
balance_amount: '余额',
|
||||
frozen_amount: '冻结金额',
|
||||
balance_after: '变动后余额',
|
||||
report_code: '报表编码',
|
||||
report_type: '报表类型',
|
||||
stat_period: '统计周期',
|
||||
generated_at: '生成时间',
|
||||
dimension: '维度',
|
||||
metric_code: '指标编码',
|
||||
metric_value: '指标值',
|
||||
scope_type: '范围类型',
|
||||
stat_at: '统计时间',
|
||||
applicant_identity: '申请人标识',
|
||||
business_type: '业务类型',
|
||||
business_identity: '业务标识',
|
||||
handler_identity: '处理人标识',
|
||||
opinion: '审批意见',
|
||||
purpose: '用途',
|
||||
field_scope: '字段范围',
|
||||
approved_at: '批准时间',
|
||||
file_uri: '文件地址',
|
||||
operator_identity: '操作人标识',
|
||||
object_identity: '对象标识',
|
||||
resource_type: '资源类型',
|
||||
handled_at: '处理时间',
|
||||
created_at: '创建时间',
|
||||
status: '状态',
|
||||
};
|
||||
const numberFields = new Set([
|
||||
'version_no',
|
||||
'level',
|
||||
'sort_no',
|
||||
'price_amount',
|
||||
'stock_quantity',
|
||||
'quantity',
|
||||
'total_amount',
|
||||
'sale_amount',
|
||||
'score',
|
||||
'amount',
|
||||
'difference_amount',
|
||||
'balance_amount',
|
||||
'frozen_amount',
|
||||
'balance_after',
|
||||
]);
|
||||
const booleanFields = new Set(['is_default', 'is_cover', 'selected']);
|
||||
const dateFields = new Set(['bill_date']);
|
||||
const datetimeFields = new Set([
|
||||
'expired_at',
|
||||
'effective_at',
|
||||
'reported_at',
|
||||
'sla_at',
|
||||
'occurred_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'paid_at',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'generated_at',
|
||||
'stat_at',
|
||||
'approved_at',
|
||||
'handled_at',
|
||||
'created_at',
|
||||
]);
|
||||
const jsonFields = new Set([
|
||||
'payload',
|
||||
'threshold',
|
||||
'gray_scope',
|
||||
'product_snapshot',
|
||||
'field_scope',
|
||||
]);
|
||||
const textareaFields = new Set(['body', 'content', 'reason', 'opinion']);
|
||||
const fieldType = (key: string): ResourceFieldType => {
|
||||
if (key === 'platform_role_code') return 'role-code';
|
||||
if (key === 'menu_identities') return 'menu-identities';
|
||||
if (key.endsWith('_identity')) return 'identity';
|
||||
if (key === 'password') return 'password';
|
||||
if (numberFields.has(key)) return 'number';
|
||||
if (booleanFields.has(key)) return 'boolean';
|
||||
if (dateFields.has(key)) return 'date';
|
||||
if (datetimeFields.has(key)) return 'datetime';
|
||||
if (jsonFields.has(key)) return 'json';
|
||||
if (textareaFields.has(key)) return 'textarea';
|
||||
return 'text';
|
||||
};
|
||||
const field = (value: string): ResourceField => {
|
||||
const key = value.replace(/!$/, '');
|
||||
return {
|
||||
key,
|
||||
label:
|
||||
labels[key] ?? (key.endsWith('_identity') ? '关联业务标识' : '业务字段'),
|
||||
type: fieldType(key),
|
||||
required: value.endsWith('!') || undefined,
|
||||
};
|
||||
};
|
||||
const titles: Record<string, string> = {
|
||||
gas_basic: '气站管理',
|
||||
gas_account: '气站账户',
|
||||
delivery_basic: '配送点管理',
|
||||
delivery_account: '配送账户',
|
||||
delivery_task: '配送任务',
|
||||
delivery_track: '配送轨迹',
|
||||
delivery_track_point: '轨迹点',
|
||||
staff_account: '服务人员',
|
||||
staff_credential: '人员资质',
|
||||
user_account: '用户账户',
|
||||
user_address: '用户地址',
|
||||
user_service_relation: '用户服务关系',
|
||||
dev_smart_cylinder_valve: '智能钢瓶阀',
|
||||
dev_device_binding: '设备绑定',
|
||||
dev_telemetry: '设备遥测',
|
||||
saf_rule: '安全规则',
|
||||
saf_event: '安全事件',
|
||||
saf_inspection: '安全检查',
|
||||
saf_event_disposal: '事件处置',
|
||||
ec_category: '商品分类',
|
||||
ec_product: '商品管理',
|
||||
ec_product_attribute: '商品属性',
|
||||
ec_product_image: '商品图片',
|
||||
ec_cart: '购物车',
|
||||
ec_order: '订单管理',
|
||||
ec_order_item: '订单明细',
|
||||
ec_review: '商品评价',
|
||||
fin_payment: '支付记录',
|
||||
fin_settlement: '财务结算',
|
||||
fin_reconciliation: '财务对账',
|
||||
cnt_content: '内容管理',
|
||||
ntf_template: '通知模板',
|
||||
cs_ticket: '客服工单',
|
||||
platfrom_account: '平台账户',
|
||||
platform_role: '平台角色',
|
||||
platform_menu: '平台菜单',
|
||||
wallet: '钱包',
|
||||
wallet_ledger: '钱包流水',
|
||||
wallet_recharge: '钱包充值',
|
||||
wallet_withdrawal: '钱包提现',
|
||||
report: '报表',
|
||||
report_item: '报表项目',
|
||||
report_metric_snapshot: '指标快照',
|
||||
aud_operation_log: '操作审计',
|
||||
aud_export_log: '导出审计',
|
||||
aud_approval: '审批审计',
|
||||
};
|
||||
const define = (
|
||||
name: string,
|
||||
resource: string,
|
||||
mode: ResourceMode,
|
||||
pageKind: ResourcePageKind,
|
||||
keys: string[],
|
||||
detailActions?: DetailAction[],
|
||||
): ResourceUiDefinition => {
|
||||
const fields = keys.map(field);
|
||||
return {
|
||||
key: name.replace(/_/g, '-'),
|
||||
name,
|
||||
resource,
|
||||
title: titles[name] ?? '业务资源',
|
||||
mode,
|
||||
pageKind,
|
||||
fields,
|
||||
requiredIdentities: fields
|
||||
.filter((item) => item.required && item.key.endsWith('_identity'))
|
||||
.map((item) => item.key),
|
||||
...(detailActions ? { detailActions } : {}),
|
||||
};
|
||||
};
|
||||
const action = (
|
||||
name: string,
|
||||
resource: string,
|
||||
keys: string[],
|
||||
payload?: Record<string, unknown>,
|
||||
): DetailAction => ({
|
||||
name,
|
||||
resource,
|
||||
fields: keys.map(field),
|
||||
...(payload ? { payload } : {}),
|
||||
});
|
||||
|
||||
/** Exact UI contract for every backend ExpectedResources entry. */
|
||||
export const resources: ResourceUiDefinition[] = [
|
||||
define('gas_basic', '/gas/gas_basic', 'writable', 'list', [
|
||||
'code!',
|
||||
'name!',
|
||||
'credit_code',
|
||||
'principal',
|
||||
'address',
|
||||
'longitude',
|
||||
'latitude',
|
||||
]),
|
||||
define('gas_account', '/gas/gas_account', 'writable', 'list', [
|
||||
'username!',
|
||||
'password!',
|
||||
'display_name',
|
||||
'role_code',
|
||||
'gas_basic_identity!',
|
||||
]),
|
||||
define('delivery_basic', '/delivery/delivery_basic', 'writable', 'list', [
|
||||
'delivery_code!',
|
||||
'name!',
|
||||
'gas_basic_identity',
|
||||
'principal',
|
||||
'address',
|
||||
]),
|
||||
define('delivery_account', '/delivery/delivery_account', 'writable', 'list', [
|
||||
'username!',
|
||||
'password!',
|
||||
'display_name',
|
||||
'role_code',
|
||||
'delivery_basic_identity!',
|
||||
]),
|
||||
define('delivery_task', '/delivery/delivery_task', 'writable', 'list', [
|
||||
'ec_order_identity!',
|
||||
'delivery_basic_identity!',
|
||||
'staff_account_identity',
|
||||
]),
|
||||
define('delivery_track', '/delivery/delivery_track', 'writable', 'list', [
|
||||
'delivery_task_identity!',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
]),
|
||||
define(
|
||||
'delivery_track_point',
|
||||
'/delivery/delivery_track_point',
|
||||
'readonly',
|
||||
'list',
|
||||
[
|
||||
'delivery_track_identity',
|
||||
'point_type',
|
||||
'occurred_at',
|
||||
'longitude',
|
||||
'latitude',
|
||||
],
|
||||
),
|
||||
define('staff_account', '/staff/account', 'writable', 'list', [
|
||||
'username!',
|
||||
'password!',
|
||||
'name!',
|
||||
'phone',
|
||||
'avatar',
|
||||
'role_code',
|
||||
'gas_basic_identity',
|
||||
'delivery_basic_identity',
|
||||
'work_status',
|
||||
]),
|
||||
define('staff_credential', '/staff/credential', 'writable', 'list', [
|
||||
'staff_account_identity!',
|
||||
'credential_type!',
|
||||
'credential_no',
|
||||
'expired_at',
|
||||
]),
|
||||
define('user_account', '/user/account', 'writable', 'list', [
|
||||
'username!',
|
||||
'password!',
|
||||
'name!',
|
||||
'phone',
|
||||
'avatar',
|
||||
'real_name',
|
||||
]),
|
||||
define('user_address', '/user/address', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'address!',
|
||||
'longitude',
|
||||
'latitude',
|
||||
'is_default',
|
||||
]),
|
||||
define(
|
||||
'user_service_relation',
|
||||
'/user/service_relation',
|
||||
'writable',
|
||||
'list',
|
||||
[
|
||||
'user_account_identity!',
|
||||
'gas_basic_identity',
|
||||
'delivery_basic_identity',
|
||||
'staff_account_identity',
|
||||
],
|
||||
),
|
||||
define(
|
||||
'dev_smart_cylinder_valve',
|
||||
'/device/dev_smart_cylinder_valve',
|
||||
'writable',
|
||||
'list',
|
||||
['device_no!', 'model', 'online_status', 'owner_identity'],
|
||||
),
|
||||
define(
|
||||
'dev_device_binding',
|
||||
'/device/dev_device_binding',
|
||||
'writable',
|
||||
'list',
|
||||
[
|
||||
'smart_cylinder_valve_identity!',
|
||||
'user_account_identity!',
|
||||
'effective_at',
|
||||
'expired_at',
|
||||
],
|
||||
),
|
||||
define('dev_telemetry', '/device/dev_telemetry', 'readonly', 'list', [
|
||||
'smart_cylinder_valve_identity',
|
||||
'reported_at',
|
||||
'payload',
|
||||
]),
|
||||
define('saf_rule', '/safety/saf_rule', 'writable', 'list', [
|
||||
'rule_code!',
|
||||
'version_no',
|
||||
'threshold',
|
||||
'action',
|
||||
'gray_scope',
|
||||
]),
|
||||
define(
|
||||
'saf_event',
|
||||
'/safety/saf_event',
|
||||
'writable',
|
||||
'list',
|
||||
[
|
||||
'event_code!',
|
||||
'level!',
|
||||
'title!',
|
||||
'smart_cylinder_valve_identity!',
|
||||
'sla_at',
|
||||
],
|
||||
[
|
||||
action('saf_event_disposal', '/safety/saf_event/:identity/disposals', [
|
||||
'action!',
|
||||
'reason!',
|
||||
]),
|
||||
],
|
||||
),
|
||||
define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'staff_account_identity!',
|
||||
'result!',
|
||||
'evidence_uri',
|
||||
]),
|
||||
define(
|
||||
'saf_event_disposal',
|
||||
'/safety/saf_event/:identity/disposals',
|
||||
'append_only',
|
||||
'list',
|
||||
['action!', 'reason!'],
|
||||
),
|
||||
define('ec_category', '/ec/ec_category', 'writable', 'tree', [
|
||||
'parent_identity',
|
||||
'name!',
|
||||
'sort_no',
|
||||
]),
|
||||
define('ec_product', '/ec/ec_product', 'writable', 'list', [
|
||||
'ec_category_identity!',
|
||||
'product_code!',
|
||||
'name!',
|
||||
'price_amount!',
|
||||
'stock_quantity',
|
||||
]),
|
||||
define(
|
||||
'ec_product_attribute',
|
||||
'/ec/ec_product_attribute',
|
||||
'writable',
|
||||
'list',
|
||||
['ec_product_identity!', 'name!', 'value!', 'sort_no'],
|
||||
),
|
||||
define('ec_product_image', '/ec/ec_product_image', 'writable', 'list', [
|
||||
'ec_product_identity!',
|
||||
'image_uri!',
|
||||
'sort_no',
|
||||
'is_cover',
|
||||
]),
|
||||
define('ec_cart', '/ec/ec_cart', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'ec_product_identity!',
|
||||
'quantity!',
|
||||
'selected',
|
||||
]),
|
||||
define('ec_order', '/ec/ec_order', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'gas_basic_identity',
|
||||
'delivery_basic_identity',
|
||||
'order_no!',
|
||||
'total_amount!',
|
||||
]),
|
||||
define('ec_order_item', '/ec/ec_order_item', 'writable', 'list', [
|
||||
'ec_order_identity!',
|
||||
'ec_product_identity!',
|
||||
'product_snapshot!',
|
||||
'quantity!',
|
||||
'sale_amount!',
|
||||
]),
|
||||
define('ec_review', '/ec/ec_review', 'writable', 'list', [
|
||||
'ec_order_identity!',
|
||||
'ec_product_identity!',
|
||||
'user_account_identity!',
|
||||
'score!',
|
||||
'content!',
|
||||
]),
|
||||
define('fin_payment', '/finance/fin_payment', 'writable', 'list', [
|
||||
'ec_order_identity!',
|
||||
'channel!',
|
||||
'amount!',
|
||||
'paid_at',
|
||||
]),
|
||||
define('fin_settlement', '/finance/fin_settlement', 'writable', 'list', [
|
||||
'settlement_no!',
|
||||
'subject_type!',
|
||||
'subject_identity!',
|
||||
'period_start!',
|
||||
'period_end!',
|
||||
]),
|
||||
define(
|
||||
'fin_reconciliation',
|
||||
'/finance/fin_reconciliation',
|
||||
'writable',
|
||||
'list',
|
||||
['channel!', 'bill_date!', 'difference_amount!'],
|
||||
),
|
||||
define('cnt_content', '/content/cnt_content', 'writable', 'list', [
|
||||
'content_type!',
|
||||
'title!',
|
||||
'body!',
|
||||
'version_no',
|
||||
'publish_status',
|
||||
]),
|
||||
define('ntf_template', '/notification/ntf_template', 'writable', 'list', [
|
||||
'template_code!',
|
||||
'channel!',
|
||||
'content!',
|
||||
]),
|
||||
define('cs_ticket', '/customer_service/cs_ticket', 'writable', 'list', [
|
||||
'user_account_identity!',
|
||||
'ticket_no!',
|
||||
'category!',
|
||||
'priority!',
|
||||
]),
|
||||
define('platfrom_account', '/platform/platfrom_account', 'writable', 'list', [
|
||||
'username!',
|
||||
'password!',
|
||||
'display_name',
|
||||
'avatar',
|
||||
'platform_role_code!',
|
||||
'phone',
|
||||
]),
|
||||
define(
|
||||
'platform_role',
|
||||
'/platform/platform_role',
|
||||
'writable',
|
||||
'list',
|
||||
['role_code!', 'name!', 'data_scope!'],
|
||||
[
|
||||
action(
|
||||
'分配菜单',
|
||||
'/platform/platform_role/:identity/menu',
|
||||
['menu_identities'],
|
||||
),
|
||||
],
|
||||
),
|
||||
define('platform_menu', '/platform/platform_menu', 'writable', 'tree', [
|
||||
'parent_identity',
|
||||
'menu_code!',
|
||||
'name!',
|
||||
'icon',
|
||||
'path',
|
||||
'sort_no',
|
||||
]),
|
||||
define('wallet', '/wallet/wallet', 'readonly', 'list', [
|
||||
'owner_identity',
|
||||
'balance_amount',
|
||||
'frozen_amount',
|
||||
'status',
|
||||
]),
|
||||
define('wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list', [
|
||||
'wallet_identity',
|
||||
'amount',
|
||||
'balance_after',
|
||||
]),
|
||||
define('wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list', [
|
||||
'wallet_identity',
|
||||
'amount',
|
||||
'status',
|
||||
]),
|
||||
define('wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list', [
|
||||
'wallet_identity',
|
||||
'amount',
|
||||
'status',
|
||||
]),
|
||||
define('report', '/report/report', 'readonly', 'list', [
|
||||
'report_code',
|
||||
'report_type',
|
||||
'stat_period',
|
||||
'generated_at',
|
||||
'status',
|
||||
]),
|
||||
define('report_item', '/report/report_item', 'readonly', 'list', [
|
||||
'report_identity',
|
||||
'dimension',
|
||||
'metric_code',
|
||||
'metric_value',
|
||||
]),
|
||||
define(
|
||||
'report_metric_snapshot',
|
||||
'/report/report_metric_snapshot',
|
||||
'readonly',
|
||||
'list',
|
||||
['metric_code', 'scope_type', 'stat_at', 'metric_value'],
|
||||
),
|
||||
define('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', [
|
||||
'operator_identity',
|
||||
'action',
|
||||
'object_identity',
|
||||
'created_at',
|
||||
]),
|
||||
define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', [
|
||||
'applicant_identity',
|
||||
'purpose',
|
||||
'field_scope',
|
||||
'approved_at',
|
||||
'file_uri',
|
||||
]),
|
||||
define(
|
||||
'aud_approval',
|
||||
'/audit/aud_approval',
|
||||
'readonly',
|
||||
'list',
|
||||
[
|
||||
'business_type',
|
||||
'business_identity',
|
||||
'applicant_identity',
|
||||
'opinion',
|
||||
'handler_identity',
|
||||
'status',
|
||||
'handled_at',
|
||||
],
|
||||
[
|
||||
action('同意', '/audit/aud_approval/:identity/approve', ['opinion'], {
|
||||
status: 'approved',
|
||||
}),
|
||||
action('驳回', '/audit/aud_approval/:identity/approve', ['opinion!'], {
|
||||
status: 'rejected',
|
||||
}),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
export const resourceByName = Object.fromEntries(
|
||||
resources.map((definition) => [definition.resource, definition]),
|
||||
) as Record<string, ResourceUiDefinition>;
|
||||
export function getResource(resourcePath: string): ResourceUiDefinition {
|
||||
const definition = resourceByName[resourcePath];
|
||||
if (!definition) throw new Error('Unknown resource: ' + resourcePath);
|
||||
return definition;
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { resourceApi } from './resource';
|
||||
|
||||
/** 服务人员管理接口。 */
|
||||
export type Staff = { identity: string; name: string; phone: string; role_code: string; work_status: string; status: string };
|
||||
export const staffApi = { list: () => resourceApi.list<Staff>('/staff/staff'), create: (data: Record<string, unknown>) => resourceApi.create<Staff>('/staff/staff', data) };
|
||||
export const staffApi = { list: () => resourceApi.list<Staff>('/staff/account'), create: (data: Record<string, unknown>) => resourceApi.create<Staff>('/staff/account', data) };
|
||||
|
||||
@@ -2,4 +2,4 @@ import { resourceApi } from './resource';
|
||||
|
||||
/** 业主客户管理接口。 */
|
||||
export type User = { identity: string; name: string; phone: string; real_name: string; status: string };
|
||||
export const userApi = { list: () => resourceApi.list<User>('/user/user'), create: (data: Record<string, unknown>) => resourceApi.create<User>('/user/user', data) };
|
||||
export const userApi = { list: () => resourceApi.list<User>('/user/account'), create: (data: Record<string, unknown>) => resourceApi.create<User>('/user/account', data) };
|
||||
|
||||
@@ -7,19 +7,24 @@ export default function usePermission() {
|
||||
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
|
||||
return (
|
||||
!route.meta?.requiresAuth ||
|
||||
!route.meta?.roles ||
|
||||
route.meta?.roles?.includes('*') ||
|
||||
route.meta?.roles?.includes(userStore.role)
|
||||
userStore.role === 'root' ||
|
||||
(!route.meta?.menuCode && !route.meta?.roles) ||
|
||||
Boolean(
|
||||
route.meta?.menuCode &&
|
||||
userStore.menuCodes.includes(route.meta.menuCode),
|
||||
) ||
|
||||
Boolean(route.meta?.roles?.includes(userStore.role))
|
||||
);
|
||||
},
|
||||
findFirstPermissionRoute(_routers: any, role = 'admin') {
|
||||
findFirstPermissionRoute(_routers: any, role = userStore.role) {
|
||||
const cloneRouters = [..._routers];
|
||||
while (cloneRouters.length) {
|
||||
const firstElement = cloneRouters.shift();
|
||||
if (
|
||||
firstElement?.meta?.roles?.find((el: string[]) => {
|
||||
return el.includes('*') || el.includes(role);
|
||||
})
|
||||
role === 'root' ||
|
||||
(firstElement?.meta?.menuCode &&
|
||||
userStore.menuCodes.includes(firstElement.meta.menuCode)) ||
|
||||
firstElement?.meta?.roles?.includes(role)
|
||||
)
|
||||
return { name: firstElement.name };
|
||||
if (firstElement?.children) {
|
||||
|
||||
12
frontend/platform_admin/src/router/routes/modules/audit.ts
Normal file
12
frontend/platform_admin/src/router/routes/modules/audit.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/audit', name: 'audit', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.audit', requiresAuth: true, icon: 'icon-apps', order: 22 },
|
||||
children: [
|
||||
{ path: 'aud-operation-log', name: 'audit-aud-operation-log', component: () => import('@/views/audit/aud_operation_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_operation_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'aud-export-log', name: 'audit-aud-export-log', component: () => import('@/views/audit/aud_export_log/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_export_log', requiresAuth: true, menuCode: 'audit' } },
|
||||
{ path: 'aud-approval', name: 'audit-aud-approval', component: () => import('@/views/audit/aud_approval/ListPage.vue'), meta: { locale: 'menu.platform.audit.aud_approval', requiresAuth: true, menuCode: 'audit' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
10
frontend/platform_admin/src/router/routes/modules/content.ts
Normal file
10
frontend/platform_admin/src/router/routes/modules/content.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/content', name: 'content', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.content', requiresAuth: true, icon: 'icon-apps', order: 17 },
|
||||
children: [
|
||||
{ path: 'cnt-content', name: 'content-cnt-content', component: () => import('@/views/content/cnt_content/ListPage.vue'), meta: { locale: 'menu.platform.content.cnt_content', requiresAuth: true, menuCode: 'content' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/customer_service', name: 'customer_service', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.customer_service', requiresAuth: true, icon: 'icon-apps', order: 19 },
|
||||
children: [
|
||||
{ path: 'cs-ticket', name: 'customer_service-cs-ticket', component: () => import('@/views/customer_service/cs_ticket/ListPage.vue'), meta: { locale: 'menu.platform.customer_service.cs_ticket', requiresAuth: true, menuCode: 'customer_service' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-apps', order: 11 },
|
||||
children: [
|
||||
{ path: 'delivery-basic', name: 'delivery-delivery-basic', component: () => import('@/views/delivery/delivery_basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_basic', requiresAuth: true, menuCode: 'delivery' } },
|
||||
{ path: 'delivery-account', name: 'delivery-delivery-account', component: () => import('@/views/delivery/delivery_account/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_account', requiresAuth: true, menuCode: 'delivery' } },
|
||||
{ path: 'delivery-task', name: 'delivery-delivery-task', component: () => import('@/views/delivery/delivery_task/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_task', requiresAuth: true, menuCode: 'delivery' } },
|
||||
{ path: 'delivery-track', name: 'delivery-delivery-track', component: () => import('@/views/delivery/delivery_track/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track', requiresAuth: true, menuCode: 'delivery' } },
|
||||
{ path: 'delivery-track-point', name: 'delivery-delivery-track-point', component: () => import('@/views/delivery/delivery_track_point/ListPage.vue'), meta: { locale: 'menu.platform.delivery.delivery_track_point', requiresAuth: true, menuCode: 'delivery' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
12
frontend/platform_admin/src/router/routes/modules/device.ts
Normal file
12
frontend/platform_admin/src/router/routes/modules/device.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/device', name: 'device', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.device', requiresAuth: true, icon: 'icon-apps', order: 14 },
|
||||
children: [
|
||||
{ path: 'dev-smart-cylinder-valve', name: 'device-dev-smart-cylinder-valve', component: () => import('@/views/device/dev_smart_cylinder_valve/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_smart_cylinder_valve', requiresAuth: true, menuCode: 'device' } },
|
||||
{ path: 'dev-device-binding', name: 'device-dev-device-binding', component: () => import('@/views/device/dev_device_binding/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_device_binding', requiresAuth: true, menuCode: 'device' } },
|
||||
{ path: 'dev-telemetry', name: 'device-dev-telemetry', component: () => import('@/views/device/dev_telemetry/ListPage.vue'), meta: { locale: 'menu.platform.device.dev_telemetry', requiresAuth: true, menuCode: 'device' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
13
frontend/platform_admin/src/router/routes/modules/ec.ts
Normal file
13
frontend/platform_admin/src/router/routes/modules/ec.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{ path: '/ec', name: 'ec', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.ec', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [
|
||||
{ path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/TreePage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-cart', name: 'ec-ec-cart', component: () => import('@/views/ec/ec_cart/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_cart', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-order', name: 'ec-ec-order', component: () => import('@/views/ec/ec_order/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-order-item', name: 'ec-ec-order-item', component: () => import('@/views/ec/ec_order_item/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_order_item', requiresAuth: true, menuCode: 'ec' } },
|
||||
{ path: 'ec-review', name: 'ec-ec-review', component: () => import('@/views/ec/ec_review/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_review', requiresAuth: true, menuCode: 'ec' } }
|
||||
] }];
|
||||
export default routes;
|
||||
12
frontend/platform_admin/src/router/routes/modules/finance.ts
Normal file
12
frontend/platform_admin/src/router/routes/modules/finance.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/finance', name: 'finance', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.finance', requiresAuth: true, icon: 'icon-apps', order: 16 },
|
||||
children: [
|
||||
{ path: 'fin-payment', name: 'finance-fin-payment', component: () => import('@/views/finance/fin_payment/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_payment', requiresAuth: true, menuCode: 'finance' } },
|
||||
{ path: 'fin-settlement', name: 'finance-fin-settlement', component: () => import('@/views/finance/fin_settlement/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_settlement', requiresAuth: true, menuCode: 'finance' } },
|
||||
{ path: 'fin-reconciliation', name: 'finance-fin-reconciliation', component: () => import('@/views/finance/fin_reconciliation/ListPage.vue'), meta: { locale: 'menu.platform.finance.fin_reconciliation', requiresAuth: true, menuCode: 'finance' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
11
frontend/platform_admin/src/router/routes/modules/gas.ts
Normal file
11
frontend/platform_admin/src/router/routes/modules/gas.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/gas', name: 'gas', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-apps', order: 10 },
|
||||
children: [
|
||||
{ path: 'gas-basic', name: 'gas-gas-basic', component: () => import('@/views/gas/gas_basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_basic', requiresAuth: true, menuCode: 'gas' } },
|
||||
{ path: 'gas-account', name: 'gas-gas-account', component: () => import('@/views/gas/gas_account/ListPage.vue'), meta: { locale: 'menu.platform.gas.gas_account', requiresAuth: true, menuCode: 'gas' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/notification', name: 'notification', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.notification', requiresAuth: true, icon: 'icon-apps', order: 18 },
|
||||
children: [
|
||||
{ path: 'ntf-template', name: 'notification-ntf-template', component: () => import('@/views/notification/ntf_template/ListPage.vue'), meta: { locale: 'menu.platform.notification.ntf_template', requiresAuth: true, menuCode: 'notification' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
@@ -1,13 +1,25 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const PLATFORM: AppRouteRecordRaw[] = [
|
||||
{ path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, roles: ['*'] } }] },
|
||||
{ path: '/gas', name: 'gas', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.gas', requiresAuth: true, icon: 'icon-fire', order: 10 }, children: [{ path: 'basic', name: 'GasBasic', component: () => import('@/views/gas/basic/ListPage.vue'), meta: { locale: 'menu.platform.gas.basic', requiresAuth: true, roles: ['*'] } }] },
|
||||
{ path: '/delivery', name: 'delivery', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.delivery', requiresAuth: true, icon: 'icon-car', order: 11 }, children: [{ path: 'basic', name: 'DeliveryBasic', component: () => import('@/views/delivery/basic/ListPage.vue'), meta: { locale: 'menu.platform.delivery.basic', requiresAuth: true, roles: ['*'] } }] },
|
||||
{ path: '/staff', name: 'staff', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-user', order: 12 }, children: [{ path: 'list', name: 'Staff', component: () => import('@/views/staff/list/ListPage.vue'), meta: { locale: 'menu.platform.staff.list', requiresAuth: true, roles: ['*'] } }] },
|
||||
{ path: '/user', name: 'user', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-user-group', order: 13 }, children: [{ path: 'list', name: 'User', component: () => import('@/views/user/list/ListPage.vue'), meta: { locale: 'menu.platform.user.list', requiresAuth: true, roles: ['*'] } }] },
|
||||
{ path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 14 }, children: [{ path: 'account', name: 'PlatformAccount', component: () => import('@/views/platform/account/ListPage.vue'), meta: { locale: 'menu.platform.config.account', requiresAuth: true, roles: ['*'] } }, { path: 'role', name: 'PlatformRole', component: () => import('@/views/platform/role/ListPage.vue'), meta: { locale: 'menu.platform.config.role', requiresAuth: true, roles: ['*'] } }, { path: 'menu', name: 'PlatformMenu', component: () => import('@/views/platform/menu/TreePage.vue'), meta: { locale: 'menu.platform.config.menu', requiresAuth: true, roles: ['*'] } }] },
|
||||
import gasRoutes from './gas';
|
||||
import deliveryRoutes from './delivery';
|
||||
import staffRoutes from './staff';
|
||||
import userRoutes from './user';
|
||||
import deviceRoutes from './device';
|
||||
import safetyRoutes from './safety';
|
||||
import ecRoutes from './ec';
|
||||
import financeRoutes from './finance';
|
||||
import contentRoutes from './content';
|
||||
import notificationRoutes from './notification';
|
||||
import customerServiceRoutes from './customer_service';
|
||||
import walletRoutes from './wallet';
|
||||
import reportRoutes from './report';
|
||||
import auditRoutes from './audit';
|
||||
const platformRoutes: AppRouteRecordRaw[] = [
|
||||
{ path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, meta: { locale: 'menu.dashboard', requiresAuth: true, icon: 'icon-dashboard', order: 0 }, children: [{ path: 'overview', name: 'DashboardOverview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { locale: 'menu.platform.dashboard', requiresAuth: true, menuCode: 'dashboard' } }] },
|
||||
{ path: '/platform', name: 'platform', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.config', requiresAuth: true, icon: 'icon-settings', order: 30 }, children: [
|
||||
{ path: 'platfrom-account', name: 'platform-platfrom-account', component: () => import('@/views/platform/platfrom_account/ListPage.vue'), meta: { locale: 'menu.platform.platform.platfrom_account', requiresAuth: true, menuCode: 'platform' } },
|
||||
{ path: 'platform-role', name: 'platform-platform-role', component: () => import('@/views/platform/platform_role/ListPage.vue'), meta: { locale: 'menu.platform.platform.platform_role', requiresAuth: true, menuCode: 'platform' } },
|
||||
{ path: 'platform-menu', name: 'platform-platform-menu', component: () => import('@/views/platform/platform_menu/TreePage.vue'), meta: { locale: 'menu.platform.platform.platform_menu', requiresAuth: true, menuCode: 'platform' } },
|
||||
] },
|
||||
];
|
||||
|
||||
export default PLATFORM;
|
||||
export default [...platformRoutes, ...gasRoutes, ...deliveryRoutes, ...staffRoutes, ...userRoutes, ...deviceRoutes, ...safetyRoutes, ...ecRoutes, ...financeRoutes, ...contentRoutes, ...notificationRoutes, ...customerServiceRoutes, ...walletRoutes, ...reportRoutes, ...auditRoutes];
|
||||
|
||||
12
frontend/platform_admin/src/router/routes/modules/report.ts
Normal file
12
frontend/platform_admin/src/router/routes/modules/report.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/report', name: 'report', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.report', requiresAuth: true, icon: 'icon-apps', order: 21 },
|
||||
children: [
|
||||
{ path: 'report', name: 'report-report', component: () => import('@/views/report/report/ListPage.vue'), meta: { locale: 'menu.platform.report.report', requiresAuth: true, menuCode: 'report' } },
|
||||
{ path: 'report-item', name: 'report-report-item', component: () => import('@/views/report/report_item/ListPage.vue'), meta: { locale: 'menu.platform.report.report_item', requiresAuth: true, menuCode: 'report' } },
|
||||
{ path: 'report-metric-snapshot', name: 'report-report-metric-snapshot', component: () => import('@/views/report/report_metric_snapshot/ListPage.vue'), meta: { locale: 'menu.platform.report.report_metric_snapshot', requiresAuth: true, menuCode: 'report' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{ path: '/safety', name: 'safety', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.safety', requiresAuth: true, icon: 'icon-apps', order: 15 }, children: [
|
||||
{ path: 'saf-rule', name: 'safety-saf-rule', component: () => import('@/views/safety/saf_rule/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_rule', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'saf-event', name: 'safety-saf-event', component: () => import('@/views/safety/saf_event/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_event', requiresAuth: true, menuCode: 'safety' } },
|
||||
{ path: 'saf-inspection', name: 'safety-saf-inspection', component: () => import('@/views/safety/saf_inspection/ListPage.vue'), meta: { locale: 'menu.platform.safety.saf_inspection', requiresAuth: true, menuCode: 'safety' } }
|
||||
] }];
|
||||
export default routes;
|
||||
11
frontend/platform_admin/src/router/routes/modules/staff.ts
Normal file
11
frontend/platform_admin/src/router/routes/modules/staff.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/staff', name: 'staff', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.staff', requiresAuth: true, icon: 'icon-apps', order: 12 },
|
||||
children: [
|
||||
{ path: 'account', name: 'staff-staff-account', component: () => import('@/views/staff/staff_account/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_account', requiresAuth: true, menuCode: 'staff' } },
|
||||
{ path: 'credential', name: 'staff-staff-credential', component: () => import('@/views/staff/staff_credential/ListPage.vue'), meta: { locale: 'menu.platform.staff.staff_credential', requiresAuth: true, menuCode: 'staff' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
12
frontend/platform_admin/src/router/routes/modules/user.ts
Normal file
12
frontend/platform_admin/src/router/routes/modules/user.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/user', name: 'user', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.user', requiresAuth: true, icon: 'icon-apps', order: 13 },
|
||||
children: [
|
||||
{ path: 'account', name: 'user-user-account', component: () => import('@/views/user/user_account/ListPage.vue'), meta: { locale: 'menu.platform.user.user_account', requiresAuth: true, menuCode: 'user' } },
|
||||
{ path: 'address', name: 'user-user-address', component: () => import('@/views/user/user_address/ListPage.vue'), meta: { locale: 'menu.platform.user.user_address', requiresAuth: true, menuCode: 'user' } },
|
||||
{ path: 'service-relation', name: 'user-user-service-relation', component: () => import('@/views/user/user_service_relation/ListPage.vue'), meta: { locale: 'menu.platform.user.user_service_relation', requiresAuth: true, menuCode: 'user' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
13
frontend/platform_admin/src/router/routes/modules/wallet.ts
Normal file
13
frontend/platform_admin/src/router/routes/modules/wallet.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
const routes: AppRouteRecordRaw[] = [{
|
||||
path: '/wallet', name: 'wallet', component: DEFAULT_LAYOUT,
|
||||
meta: { locale: 'menu.platform.wallet', requiresAuth: true, icon: 'icon-apps', order: 20 },
|
||||
children: [
|
||||
{ path: 'wallet', name: 'wallet-wallet', component: () => import('@/views/wallet/wallet/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet', requiresAuth: true, menuCode: 'wallet' } },
|
||||
{ path: 'wallet-ledger', name: 'wallet-wallet-ledger', component: () => import('@/views/wallet/wallet_ledger/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_ledger', requiresAuth: true, menuCode: 'wallet' } },
|
||||
{ path: 'wallet-recharge', name: 'wallet-wallet-recharge', component: () => import('@/views/wallet/wallet_recharge/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_recharge', requiresAuth: true, menuCode: 'wallet' } },
|
||||
{ path: 'wallet-withdrawal', name: 'wallet-wallet-withdrawal', component: () => import('@/views/wallet/wallet_withdrawal/ListPage.vue'), meta: { locale: 'menu.platform.wallet.wallet_withdrawal', requiresAuth: true, menuCode: 'wallet' } }
|
||||
],
|
||||
}];
|
||||
export default routes;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user