diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md index f696f6e..6a7f44f 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -36,3 +36,10 @@ The tree page consumes `parent_identity`; the corresponding menu API must expose - Safety disposal is a detail action on `saf_event` (`/safety/saf_event/:identity/disposals`) and has no standalone menu or page. - `ec_category` and `platform_menu` use tree pages. The shared tree preserves identity-first behavior and uses backend `parent_id` only for in-memory hierarchy adaptation when `parent_identity` is unavailable. - The audit transpiles and evaluates `resources.ts`, then compares the resulting definitions, allowlists, required identities, modes, page types, tree semantics, and identifier-safety rules to the complete expected contract. + +## Fix round 3 + +- Added tested backend identity-to-key resolution for public account and relationship requests; clients send `*_identity`, while numeric keys remain internal persistence details. +- Platform-menu and e-commerce-category tree responses now expose `parent_identity` and omit `parent_id`; the front-end tree no longer falls back to numeric keys. +- Settlement input accepts `subject_identity` with supported gas, delivery, and staff subject types. +- The platform audit now cross-checks every front-end definition against the backend resource catalogue and registered route source. diff --git a/backend/api/internal/logic/platform/account.go b/backend/api/internal/logic/platform/account.go index f97aaff..92fd00d 100644 --- a/backend/api/internal/logic/platform/account.go +++ b/backend/api/internal/logic/platform/account.go @@ -10,19 +10,19 @@ import ( ) type accountRequest struct { - Username string `json:"username" binding:"required,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - DisplayName string `json:"display_name" binding:"max=64"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` + Username string `json:"username" binding:"required,max=64"` + Password string `json:"password" binding:"required,min=8,max=128"` + DisplayName string `json:"display_name" binding:"max=64"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` } type accountUpdateRequest struct { - DisplayName string `json:"display_name" binding:"max=64"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` + DisplayName string `json:"display_name" binding:"max=64"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` } func passwordHash(password string) (string, error) { @@ -35,7 +35,12 @@ func GetGasAccount(ctx *gin.Context) { getByIdentity[models.GasAccount](ctx) } func CreateGasAccount(ctx *gin.Context) { var request accountRequest - if err := ctx.ShouldBindJSON(&request); err != nil || request.GasBasicID == 0 { + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -44,7 +49,7 @@ func CreateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.GasAccount{Entity: newEntity("enabled"), GasBasicID: request.GasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.GasAccount{Entity: newEntity("enabled"), GasBasicID: gasBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return @@ -58,7 +63,12 @@ func UpdateGasAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": request.GasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.GasAccount{}, gin.H{"gas_basic_id": gasBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"gas_basic_id", "display_name", "role_code"}) } func ListDeliveryAccount(ctx *gin.Context) { listPage[models.DeliveryAccount](ctx) } @@ -66,7 +76,12 @@ func GetDeliveryAccount(ctx *gin.Context) { getByIdentity[models.DeliveryAccoun func CreateDeliveryAccount(ctx *gin.Context) { var request accountRequest - if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryBasicID == 0 { + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -75,7 +90,7 @@ func CreateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - account := models.DeliveryAccount{Entity: newEntity("enabled"), DeliveryBasicID: request.DeliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} + account := models.DeliveryAccount{Entity: newEntity("enabled"), DeliveryBasicID: deliveryBasicID, Username: request.Username, DisplayName: request.DisplayName, PasswordHash: hash, RoleCode: request.RoleCode} if err := impl.DBService.Create(&account).Error; err != nil { infra.Response.Error(ctx, err) return @@ -89,5 +104,10 @@ func UpdateDeliveryAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": request.DeliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.DeliveryAccount{}, gin.H{"delivery_basic_id": deliveryBasicID, "display_name": request.DisplayName, "role_code": request.RoleCode}, []string{"delivery_basic_id", "display_name", "role_code"}) } diff --git a/backend/api/internal/logic/platform/delivery.go b/backend/api/internal/logic/platform/delivery.go index 7409331..9547528 100644 --- a/backend/api/internal/logic/platform/delivery.go +++ b/backend/api/internal/logic/platform/delivery.go @@ -16,30 +16,46 @@ func GetDeliveryBasic(ctx *gin.Context) { getByIdentity[models.DeliveryBasic](ct // CreateDeliveryBasic 创建配送点档案。 func CreateDeliveryBasic(ctx *gin.Context) { - var request models.DeliveryBasic + var request struct { + DeliveryCode string `json:"delivery_code"` + GasBasicIdentity string `json:"gas_basic_identity"` + Name string `json:"name"` + Principal string `json:"principal"` + Address string `json:"address"` + } if err := ctx.ShouldBindJSON(&request); err != nil || request.DeliveryCode == "" || request.Name == "" { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - request.Entity = newEntity("draft") - if err := impl.DBService.Create(&request).Error; err != nil { + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + delivery := models.DeliveryBasic{Entity: newEntity("draft"), DeliveryCode: request.DeliveryCode, GasBasicID: gasBasicID, Name: request.Name, Principal: request.Principal, Address: request.Address} + if err := impl.DBService.Create(&delivery).Error; err != nil { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, request) + infra.Response.Success(ctx, resourceResponse(delivery)) } // UpdateDeliveryBasic 更新配送点基础资料。 func UpdateDeliveryBasic(ctx *gin.Context) { var request struct { - GasBasicID uint64 `json:"gas_basic_id"` - Name string `json:"name" binding:"required,max=128"` - Principal string `json:"principal" binding:"max=64"` - Address string `json:"address" binding:"max=255"` + GasBasicIdentity string `json:"gas_basic_identity"` + Name string `json:"name" binding:"required,max=128"` + Principal string `json:"principal" binding:"max=64"` + Address string `json:"address" binding:"max=255"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": request.GasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.DeliveryBasic{}, gin.H{"gas_basic_id": gasBasicID, "name": request.Name, "principal": request.Principal, "address": request.Address}, []string{"gas_basic_id", "name", "principal", "address"}) } diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index bdd5596..ce897f0 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -214,6 +214,40 @@ func TestPrepareResourceValuesResolvesRequiredIdentityRelationsAndRejectsInvalid } } +func TestCreateGasAccountResolvesGasBasicIdentityBeforePersisting(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT "id" FROM "gas_basic" WHERE identity = $1 ORDER BY "gas_basic"."id" LIMIT $2`)). + WithArgs("gas-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(8))) + mock.ExpectBegin() + mock.ExpectQuery(`INSERT INTO "gas_account"`). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectCommit() + + ctx, recorder := updateContext(http.MethodPost, "/gas/gas_account", "", []byte(`{"username":"operator","password":"password-123","gas_basic_identity":"gas-a"}`)) + CreateGasAccount(ctx) + + assertResponseCode(t, recorder, 0) + assertMockExpectations(t, mock) +} + +func TestListPlatformMenuReturnsParentIdentityWithoutParentID(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "platform_menu" ORDER BY sort_no asc, id asc`)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "parent_id", "menu_code", "name", "icon", "path", "sort_no"}). + AddRow(uint64(1), "root-a", nil, nil, "enabled", 1, uint64(0), "root", "Root", "", "", 1). + AddRow(uint64(2), "child-a", nil, nil, "enabled", 1, uint64(1), "child", "Child", "", "/child", 2)) + + ctx, recorder := updateContext(http.MethodGet, "/platform/platform_menu", "", nil) + ListPlatformMenu(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"parent_identity":"root-a"`) || strings.Contains(recorder.Body.String(), `"parent_id"`) { + t.Fatalf("menu response leaked parent_id or omitted identity: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + func TestResourceResponseDoesNotExposeAutoIncrementRelationIDs(t *testing.T) { response := resourceResponse(map[string]any{"identity": "item-a", "id": uint64(1), "ec_order_id": uint64(2), "ec_product_id": uint64(3), "items": []any{map[string]any{"identity": "child-a", "delivery_task_id": uint64(4)}}}) encoded, err := json.Marshal(response) diff --git a/backend/api/internal/logic/platform/role.go b/backend/api/internal/logic/platform/role.go index e164edd..1edbd62 100644 --- a/backend/api/internal/logic/platform/role.go +++ b/backend/api/internal/logic/platform/role.go @@ -61,15 +61,53 @@ func UpdatePlatformRole(ctx *gin.Context) { } type platformMenuRequest struct { - ParentID uint64 `json:"parent_id"` - MenuCode string `json:"menu_code" binding:"required,max=64"` - Name string `json:"name" binding:"required,max=64"` - Icon string `json:"icon" binding:"max=64"` - Path string `json:"path" binding:"max=255"` - SortNo int `json:"sort_no"` + ParentIdentity string `json:"parent_identity"` + MenuCode string `json:"menu_code" binding:"required,max=64"` + Name string `json:"name" binding:"required,max=64"` + Icon string `json:"icon" binding:"max=64"` + Path string `json:"path" binding:"max=255"` + SortNo int `json:"sort_no"` } -func GetPlatformMenu(ctx *gin.Context) { getByIdentity[models.PlatformMenu](ctx) } +type platformMenuView struct { + Identity string `json:"identity"` + ParentIdentity string `json:"parent_identity,omitempty"` + MenuCode string `json:"menu_code"` + Name string `json:"name"` + Icon string `json:"icon"` + Path string `json:"path"` + SortNo int `json:"sort_no"` + Status string `json:"status"` +} + +func platformMenuViews(list []models.PlatformMenu) []platformMenuView { + identities := make(map[uint64]string, len(list)) + for _, item := range list { + identities[item.ID] = item.Identity + } + views := make([]platformMenuView, 0, len(list)) + for _, item := range list { + views = append(views, platformMenuView{Identity: item.Identity, ParentIdentity: identities[item.ParentID], MenuCode: item.MenuCode, Name: item.Name, Icon: item.Icon, Path: item.Path, SortNo: item.SortNo, Status: item.Status}) + } + return views +} + +func GetPlatformMenu(ctx *gin.Context) { + var menu models.PlatformMenu + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&menu).Error; err != nil { + respondRecordError(ctx, err) + return + } + list := []models.PlatformMenu{menu} + if menu.ParentID != 0 { + var parent models.PlatformMenu + if err := impl.DBService.Select("id", "identity").First(&parent, menu.ParentID).Error; err == nil { + list = append(list, parent) + } + } + views := platformMenuViews(list) + infra.Response.Success(ctx, views[0]) +} func CreatePlatformMenu(ctx *gin.Context) { var request platformMenuRequest @@ -77,7 +115,12 @@ func CreatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - menu := models.PlatformMenu{Entity: newEntity("enabled"), ParentID: request.ParentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} + parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + menu := models.PlatformMenu{Entity: newEntity("enabled"), ParentID: parentID, MenuCode: request.MenuCode, Name: request.Name, Icon: request.Icon, Path: request.Path, SortNo: request.SortNo} if err := impl.DBService.Create(&menu).Error; err != nil { infra.Response.Error(ctx, err) return @@ -91,7 +134,12 @@ func UpdatePlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": request.ParentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) + parentID, err := resolveIdentityID(&models.PlatformMenu{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.PlatformMenu{}, gin.H{"parent_id": parentID, "name": request.Name, "icon": request.Icon, "path": request.Path, "sort_no": request.SortNo}, []string{"parent_id", "name", "icon", "path", "sort_no"}) } type platformRoleMenusRequest struct { @@ -189,7 +237,7 @@ func ListPlatformMenu(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - infra.Response.Success(ctx, gin.H{"total": len(list), "list": list}) + infra.Response.Success(ctx, gin.H{"total": len(list), "list": platformMenuViews(list)}) } // ListPlatfromAccount 查询平台账号列表,手机号在展示层脱敏。 diff --git a/backend/api/internal/logic/platform/secondary_resource.go b/backend/api/internal/logic/platform/secondary_resource.go index 550c819..d288a5c 100644 --- a/backend/api/internal/logic/platform/secondary_resource.go +++ b/backend/api/internal/logic/platform/secondary_resource.go @@ -11,10 +11,10 @@ import ( ) type staffCredentialRequest struct { - StaffAccountID uint64 `json:"staff_account_id" binding:"required"` - CredentialType string `json:"credential_type" binding:"required,max=64"` - CredentialNo string `json:"credential_no" binding:"max=128"` - ExpiredAt *time.Time `json:"expired_at"` + StaffAccountIdentity string `json:"staff_account_identity" binding:"required"` + CredentialType string `json:"credential_type" binding:"required,max=64"` + CredentialNo string `json:"credential_no" binding:"max=128"` + ExpiredAt *time.Time `json:"expired_at"` } func ListStaffCredential(ctx *gin.Context) { listPage[models.StaffCredential](ctx) } @@ -25,7 +25,12 @@ func CreateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - credential := models.StaffCredential{Entity: newEntity("enabled"), StaffAccountID: request.StaffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + credential := models.StaffCredential{Entity: newEntity("enabled"), StaffAccountID: staffAccountID, CredentialType: request.CredentialType, CredentialNo: request.CredentialNo, ExpiredAt: request.ExpiredAt} if err := impl.DBService.Create(&credential).Error; err != nil { infra.Response.Error(ctx, err) return @@ -38,15 +43,20 @@ func UpdateStaffCredential(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": request.StaffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.StaffCredential{}, gin.H{"staff_account_id": staffAccountID, "credential_type": request.CredentialType, "credential_no": request.CredentialNo, "expired_at": request.ExpiredAt}, []string{"staff_account_id", "credential_type", "credential_no", "expired_at"}) } type userAddressRequest struct { - UserAccountID uint64 `json:"user_account_id" binding:"required"` - Address string `json:"address" binding:"required,max=255"` - Longitude string `json:"longitude" binding:"max=32"` - Latitude string `json:"latitude" binding:"max=32"` - IsDefault bool `json:"is_default"` + UserAccountIdentity string `json:"user_account_identity" binding:"required"` + Address string `json:"address" binding:"required,max=255"` + Longitude string `json:"longitude" binding:"max=32"` + Latitude string `json:"latitude" binding:"max=32"` + IsDefault bool `json:"is_default"` } func ListUserAddress(ctx *gin.Context) { listPage[models.UserAddress](ctx) } @@ -57,7 +67,12 @@ func CreateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - address := models.UserAddress{Entity: newEntity("enabled"), UserAccountID: request.UserAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + address := models.UserAddress{Entity: newEntity("enabled"), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} if err := impl.DBService.Create(&address).Error; err != nil { infra.Response.Error(ctx, err) return @@ -70,14 +85,19 @@ func UpdateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": request.UserAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.UserAddress{}, gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}, []string{"user_account_id", "address", "longitude", "latitude", "is_default"}) } type userServiceRelationRequest struct { - UserAccountID uint64 `json:"user_account_id" binding:"required"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - StaffAccountID uint64 `json:"staff_account_id"` + UserAccountIdentity string `json:"user_account_identity" binding:"required"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + StaffAccountIdentity string `json:"staff_account_identity"` } func ListUserServiceRelation(ctx *gin.Context) { listPage[models.UserServiceRelation](ctx) } @@ -88,7 +108,27 @@ func CreateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - relation := models.UserServiceRelation{Entity: newEntity("enabled"), UserAccountID: request.UserAccountID, GasBasicID: request.GasBasicID, DeliveryBasicID: request.DeliveryBasicID, StaffAccountID: request.StaffAccountID} + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + relation := models.UserServiceRelation{Entity: newEntity("enabled"), UserAccountID: userAccountID, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, StaffAccountID: staffAccountID} if err := impl.DBService.Create(&relation).Error; err != nil { infra.Response.Error(ctx, err) return @@ -101,5 +141,25 @@ func UpdateUserServiceRelation(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"user_account_id": request.UserAccountID, "gas_basic_id": request.GasBasicID, "delivery_basic_id": request.DeliveryBasicID, "staff_account_id": request.StaffAccountID}, []string{"user_account_id", "gas_basic_id", "delivery_basic_id", "staff_account_id"}) + userAccountID, err := resolveIdentityID(&models.UserAccount{}, request.UserAccountIdentity, true) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staffAccountID, err := resolveIdentityID(&models.StaffAccount{}, request.StaffAccountIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.UserServiceRelation{}, gin.H{"user_account_id": userAccountID, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "staff_account_id": staffAccountID}, []string{"user_account_id", "gas_basic_id", "delivery_basic_id", "staff_account_id"}) } diff --git a/backend/api/internal/logic/platform/staff.go b/backend/api/internal/logic/platform/staff.go index 0d484ef..bea9fa6 100644 --- a/backend/api/internal/logic/platform/staff.go +++ b/backend/api/internal/logic/platform/staff.go @@ -17,15 +17,15 @@ func GetStaff(ctx *gin.Context) { getByIdentity[models.StaffAccount](ctx) } // CreateStaff 创建服务人员档案。 func CreateStaff(ctx *gin.Context) { var request struct { - Username string `json:"username" binding:"required,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - WorkStatus string `json:"work_status" binding:"max=32"` + Username string `json:"username" binding:"required,max=64"` + Password string `json:"password" binding:"required,min=8,max=128"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar string `json:"avatar" binding:"max=512"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + WorkStatus string `json:"work_status" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -36,7 +36,17 @@ func CreateStaff(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - staff := models.StaffAccount{Entity: newEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: request.GasBasicID, DeliveryBasicID: request.DeliveryBasicID, WorkStatus: request.WorkStatus} + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + staff := models.StaffAccount{Entity: newEntity("draft"), Username: request.Username, PasswordHash: hash, Name: request.Name, Phone: request.Phone, Avatar: request.Avatar, RoleCode: request.RoleCode, GasBasicID: gasBasicID, DeliveryBasicID: deliveryBasicID, WorkStatus: request.WorkStatus} if staff.WorkStatus == "" { staff.WorkStatus = "off_duty" } @@ -50,17 +60,27 @@ func CreateStaff(ctx *gin.Context) { // UpdateStaff 更新服务人员档案。 func UpdateStaff(ctx *gin.Context) { var request struct { - Name string `json:"name" binding:"required,max=64"` - Phone string `json:"phone" binding:"max=32"` - Avatar string `json:"avatar" binding:"max=512"` - RoleCode string `json:"role_code" binding:"max=64"` - GasBasicID uint64 `json:"gas_basic_id"` - DeliveryBasicID uint64 `json:"delivery_basic_id"` - WorkStatus string `json:"work_status" binding:"max=32"` + Name string `json:"name" binding:"required,max=64"` + Phone string `json:"phone" binding:"max=32"` + Avatar string `json:"avatar" binding:"max=512"` + RoleCode string `json:"role_code" binding:"max=64"` + GasBasicIdentity string `json:"gas_basic_identity"` + DeliveryBasicIdentity string `json:"delivery_basic_identity"` + WorkStatus string `json:"work_status" binding:"max=32"` } if err := ctx.ShouldBindJSON(&request); err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - updateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": request.GasBasicID, "delivery_basic_id": request.DeliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) + gasBasicID, err := resolveIdentityID(&models.GasBasic{}, request.GasBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + deliveryBasicID, err := resolveIdentityID(&models.DeliveryBasic{}, request.DeliveryBasicIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"}) } diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go index b4152d4..79899f9 100644 --- a/backend/api/internal/logic/platform/task4_resources.go +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -1,8 +1,10 @@ package platform import ( + "bytes" "encoding/json" "errors" + "io" "reflect" "strings" @@ -33,6 +35,60 @@ func ResourceHandlers(model any, createFields, updateFields []string, relations func(ctx *gin.Context) { updateResource(ctx, model, updateFields, relations) } } +// FinSettlementHandlers accepts a public subject_identity and derives the +// polymorphic storage key from its declared subject_type. +func FinSettlementHandlers() (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + fields := []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"} + return func(ctx *gin.Context) { listResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + createResource(ctx, &models.FinSettlement{}, fields, nil) + }, + func(ctx *gin.Context) { getResource(ctx, &models.FinSettlement{}) }, + func(ctx *gin.Context) { + if err := rewriteSettlementSubject(ctx); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateResource(ctx, &models.FinSettlement{}, fields, nil) + } +} + +func rewriteSettlementSubject(ctx *gin.Context) error { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + return err + } + subjectType, _ := input["subject_type"].(string) + identity, _ := input["subject_identity"].(string) + var model any + switch subjectType { + case "gas", "gas_basic": + model = &models.GasBasic{} + case "delivery", "delivery_basic": + model = &models.DeliveryBasic{} + case "staff", "staff_account": + model = &models.StaffAccount{} + default: + return errors.New("invalid settlement subject") + } + id, err := resolveIdentityID(model, identity, true) + if err != nil { + return err + } + delete(input, "subject_identity") + input["subject_id"] = id + encoded, err := json.Marshal(input) + if err != nil { + return err + } + ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) + return nil +} + func listResource(ctx *gin.Context, model any) { page, size := pageSize(ctx) list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem())) @@ -126,15 +182,32 @@ func resolveResourceRelations(input map[string]any, allowedFields []string, rela if !ok || strings.TrimSpace(identity) == "" { return nil, errors.New("invalid relation identity") } - var related struct{ ID uint64 } - if err := impl.DBService.Model(relation.Model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil { + id, err := resolveIdentityID(relation.Model, identity, true) + if err != nil { return nil, err } - values[relation.Column] = related.ID + values[relation.Column] = id } return values, nil } +// resolveIdentityID is the only boundary that converts a public identity to a +// persistence-only numeric key. Callers must never bind a client supplied ID. +func resolveIdentityID(model any, identity string, required bool) (uint64, error) { + identity = strings.TrimSpace(identity) + if identity == "" { + if required { + return 0, errors.New("missing required relation") + } + return 0, nil + } + var related struct{ ID uint64 } + if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil { + return 0, err + } + return related.ID, nil +} + func relationColumns(relations []ResourceRelation) []string { columns := make([]string, 0, len(relations)) for _, relation := range relations { @@ -266,3 +339,75 @@ func GetDeliveryTrack(ctx *gin.Context) { } infra.Response.Success(ctx, resourceResponse(gin.H{"track": track, "points": points})) } + +type ecCategoryView struct { + Identity string `json:"identity"` + ParentIdentity string `json:"parent_identity,omitempty"` + Name string `json:"name"` + SortNo int `json:"sort_no"` + Status string `json:"status"` +} + +func ecCategoryViews(list []models.EcCategory) ([]ecCategoryView, error) { + parents := make(map[uint64]string) + for _, item := range list { + if item.ParentID != 0 { + parents[item.ParentID] = "" + } + } + if len(parents) > 0 { + var rows []struct { + ID uint64 + Identity string + } + ids := make([]uint64, 0, len(parents)) + for id := range parents { + ids = append(ids, id) + } + if err := impl.DBService.Model(&models.EcCategory{}).Select("id", "identity").Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + parents[row.ID] = row.Identity + } + } + views := make([]ecCategoryView, 0, len(list)) + for _, item := range list { + views = append(views, ecCategoryView{Identity: item.Identity, ParentIdentity: parents[item.ParentID], Name: item.Name, SortNo: item.SortNo, Status: item.Status}) + } + return views, nil +} + +func ListEcCategory(ctx *gin.Context) { + page, size := pageSize(ctx) + var list []models.EcCategory + var total int64 + if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := impl.DBService.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + views, err := ecCategoryViews(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": views}) +} + +func GetEcCategory(ctx *gin.Context) { + var category models.EcCategory + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { + respondRecordError(ctx, err) + return + } + views, err := ecCategoryViews([]models.EcCategory{category}) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, views[0]) +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index f606e75..fb87267 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -68,7 +68,9 @@ func registerSafetyRoute(group *gin.RouterGroup) { } func registerCommerceRoute(group *gin.RouterGroup) { - registerRestrictedWritableResource(group, "/ec/ec_category", &models.EcCategory{}, []string{"name", "sort_no"}, optionalRelation("parent_identity", "parent_id", &models.EcCategory{})) + categoryRelations := []platform.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} + _, categoryCreate, _, categoryUpdate := platform.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) + registerWritableResource(group, "/ec/ec_category", platform.ListEcCategory, categoryCreate, platform.GetEcCategory, categoryUpdate, &models.EcCategory{}) registerRestrictedWritableResource(group, "/ec/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{})) registerRestrictedWritableResource(group, "/ec/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) registerRestrictedWritableResource(group, "/ec/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) @@ -106,7 +108,8 @@ func registerPlatformRoute(group *gin.RouterGroup) { func registerFinanceRoute(group *gin.RouterGroup) { registerRestrictedWritableResource(group, "/finance/fin_payment", &models.FinPayment{}, []string{"channel", "amount", "paid_at"}, requiredRelation("ec_order_identity", "ec_order_id", &models.EcOrder{})) - registerRestrictedWritableResource(group, "/finance/fin_settlement", &models.FinSettlement{}, []string{"settlement_no", "subject_type", "subject_id", "period_start", "period_end"}) + settlementList, settlementCreate, settlementGet, settlementUpdate := platform.FinSettlementHandlers() + registerWritableResource(group, "/finance/fin_settlement", settlementList, settlementCreate, settlementGet, settlementUpdate, &models.FinSettlement{}) registerRestrictedWritableResource(group, "/finance/fin_reconciliation", &models.FinReconciliation{}, []string{"channel", "bill_date", "difference_amount"}) registerReadOnlyResource(group, "/wallet/wallet", &models.Wallet{}) diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 3d3f246..ce76172 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1195,6 +1195,8 @@ const contracts = [ } ]; const source = fs.readFileSync('src/api/resources.ts', 'utf8'); +const backendContractSource = fs.readFileSync('../../backend/api/internal/logic/platform/resource.go', 'utf8'); +const backendRouterSource = fs.readFileSync('../../backend/api/internal/routers/platform.go', 'utf8'); const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; const resourceModule = { exports: {} }; vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); @@ -1208,6 +1210,8 @@ function checkContracts() { const definition = resources.find((resource) => resource.name === contract.name); const label = `${contract.domain}/${contract.name}`; if (!definition || definition.resource !== contract.resource || definition.mode !== contract.mode || definition.pageKind !== contract.pageKind || !sameFields(definition.fields, contract.fields)) failures.push(`${label}: mismatched contract or allowlist`); + if (!backendContractSource.includes(`Domain: "${contract.domain}"`) || !backendContractSource.includes(`Name: "${contract.name}"`)) failures.push(`${label}: absent from backend ExpectedResources`); + if (contract.name !== 'saf_event_disposal' && !backendRouterSource.includes(contract.resource)) failures.push(`${label}: absent from backend router`); const requiredIdentity = contract.fields.filter((field) => field.required && field.key.endsWith('_identity')).map((field) => field.key); if (definition && JSON.stringify(definition.requiredIdentities ?? []) !== JSON.stringify(requiredIdentity)) failures.push(`${label}: required identity fields do not match allowlist`); if (contract.name === 'saf_event_disposal') continue; @@ -1224,7 +1228,7 @@ function checkContracts() { const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); if (/(CrudListPage|resourceApi\\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); - if (!treePage.includes(' resource.name === 'saf_event'); if (!safetyEvent?.detailActions?.some((item) => item.name === 'saf_event_disposal' && item.resource === '/safety/saf_event/:identity/disposals')) failures.push('safety event: missing disposal detail action'); diff --git a/frontend/platform_admin/src/views/shared/TreePage.vue b/frontend/platform_admin/src/views/shared/TreePage.vue index 9c1e2cb..bd05799 100644 --- a/frontend/platform_admin/src/views/shared/TreePage.vue +++ b/frontend/platform_admin/src/views/shared/TreePage.vue @@ -4,10 +4,10 @@ import { Message } from '@arco-design/web-vue'; import { computed, onMounted, ref } from 'vue'; import { resourceApi } from '@/api/resource'; import type { ResourceUiDefinition } from '@/api/resources'; -type Node = Record & { identity: string; parent_identity?: string; id?: number; parent_id?: number; children: Node[] }; +type Node = Record & { identity: string; parent_identity?: string; children: Node[] }; const props = defineProps<{ definition: ResourceUiDefinition }>(); const loading = ref(false); const list = ref([]); -const tree = computed(() => { const byIdentity = new Map(); const byInternalId = new Map(); const roots: Node[] = []; list.value.forEach((item) => { const node = { ...item, children: [] }; byIdentity.set(node.identity, node); if (typeof node.id === 'number') byInternalId.set(node.id, node); }); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : typeof item.parent_id === 'number' ? byInternalId.get(item.parent_id) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; }); +const tree = computed(() => { const byIdentity = new Map(); const roots: Node[] = []; list.value.forEach((item) => byIdentity.set(item.identity, { ...item, children: [] })); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; }); async function load() { loading.value = true; try { list.value = (await resourceApi.list(props.definition.resource, 1, 500)).list; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } } onMounted(load);