From fa21507a6869ebeecfae0213f4c3b67a3d15dbfa Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 17:55:59 +0800 Subject: [PATCH] feat: improve gas station management workflow --- backend/api/internal/logic/common/resource.go | 17 +- .../internal/logic/common/resource_test.go | 32 ++ .../logic/platform/delivery/delivery.go | 24 +- .../api/internal/logic/platform/gas/gas.go | 81 ++++ .../logic/platform/platform/access.go | 3 + .../logic/platform/platform/access_test.go | 10 + .../api/internal/models/gas_basic_review.go | 23 + backend/api/internal/routers/platform.go | 9 +- backend/api/internal/routers/platform_test.go | 12 + .../src/views/shared/CrudListPage.vue | 398 +++++++++++++++++- 10 files changed, 584 insertions(+), 25 deletions(-) create mode 100644 backend/api/internal/models/gas_basic_review.go diff --git a/backend/api/internal/logic/common/resource.go b/backend/api/internal/logic/common/resource.go index 8f198c4..531c545 100644 --- a/backend/api/internal/logic/common/resource.go +++ b/backend/api/internal/logic/common/resource.go @@ -386,9 +386,8 @@ func ResourceResponse(value any) any { return stripInternalIDs(decoded) } -// PublicResourceResponse additionally resolves persisted relation keys into -// their public identities. It is used by list/detail endpoints so an edit form -// can round-trip the relation without ever receiving a surrogate database ID. +// PublicResourceResponse preserves a resource's own ID for administrative +// display while resolving and removing persistence-only relation IDs. func PublicResourceResponse(value any) (any, error) { encoded, err := json.Marshal(value) if err != nil { @@ -450,7 +449,7 @@ type relationIdentityRecord struct { func projectRelationIdentities(value any) (any, error) { groups := map[string]*relationIdentityGroup{} - collectRelationIdentityReferences(value, groups) + collectRelationIdentityReferences(value, groups, true) groupKeys := make([]string, 0, len(groups)) for key := range groups { groupKeys = append(groupKeys, key) @@ -477,12 +476,14 @@ func projectRelationIdentities(value any) (any, error) { return value, nil } -func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup) { +func collectRelationIdentityReferences(value any, groups map[string]*relationIdentityGroup, preserveRecordID bool) { switch data := value.(type) { case map[string]any: for key, item := range data { if key == "id" { - delete(data, key) + if !preserveRecordID { + delete(data, key) + } continue } if strings.HasSuffix(key, "_id") { @@ -515,11 +516,11 @@ func collectRelationIdentityReferences(value any, groups map[string]*relationIde delete(data, key) continue } - collectRelationIdentityReferences(item, groups) + collectRelationIdentityReferences(item, groups, false) } case []any: for _, item := range data { - collectRelationIdentityReferences(item, groups) + collectRelationIdentityReferences(item, groups, preserveRecordID) } } } diff --git a/backend/api/internal/logic/common/resource_test.go b/backend/api/internal/logic/common/resource_test.go index f38a16f..e650f83 100644 --- a/backend/api/internal/logic/common/resource_test.go +++ b/backend/api/internal/logic/common/resource_test.go @@ -50,6 +50,38 @@ func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) { } } +func TestPublicResourceResponsePreservesOnlyRecordID(t *testing.T) { + got, err := PublicResourceResponse(map[string]any{ + "id": uint64(7), "identity": "record", + "child": map[string]any{"id": uint64(8), "identity": "child"}, + }) + if err != nil { + t.Fatal(err) + } + record := got.(map[string]any) + if record["id"] != float64(7) { + t.Fatalf("record ID = %#v, want 7", record["id"]) + } + if _, exists := record["child"].(map[string]any)["id"]; exists { + t.Fatal("nested database ID was exposed") + } +} + +func TestPublicResourceResponsePreservesListRecordIDs(t *testing.T) { + got, err := PublicResourceResponse([]map[string]any{ + {"id": uint64(11), "identity": "first"}, + {"id": uint64(12), "identity": "second"}, + }) + if err != nil { + t.Fatal(err) + } + list := got.([]any) + if list[0].(map[string]any)["id"] != float64(11) || + list[1].(map[string]any)["id"] != float64(12) { + t.Fatalf("list record IDs were not preserved: %#v", list) + } +} + func TestPublicFieldProtectionMasksGasorderContacts(t *testing.T) { value := map[string]any{"contact_name": "张三", "contact_phone": "13800138000"} ProtectPublicFields(value, false, false, false) diff --git a/backend/api/internal/logic/platform/delivery/delivery.go b/backend/api/internal/logic/platform/delivery/delivery.go index cafa75a..c462795 100644 --- a/backend/api/internal/logic/platform/delivery/delivery.go +++ b/backend/api/internal/logic/platform/delivery/delivery.go @@ -1,16 +1,38 @@ package delivery import ( + "strings" + "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // ListDeliveryBasic 查询配送点分页列表。 -func ListDeliveryBasic(ctx *gin.Context) { common.ListPage[models.DeliveryBasic](ctx) } +func ListDeliveryBasic(ctx *gin.Context) { + gasIdentities := strings.Split(strings.TrimSpace(ctx.Query("gas_basic_identities")), ",") + if len(gasIdentities) == 1 && gasIdentities[0] == "" { + gasIdentities = nil + } + if len(gasIdentities) > 100 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + common.ListPageFiltered[models.DeliveryBasic](ctx, func(query *gorm.DB) *gorm.DB { + if len(gasIdentities) == 0 { + return query + } + return query.Where( + "gas_basic_id IN (SELECT id FROM gas_basic WHERE identity IN ? AND status <> ?)", + gasIdentities, + common.StatusArchived, + ) + }) +} // GetDeliveryBasic 查询一个配送点。 func GetDeliveryBasic(ctx *gin.Context) { common.GetByIdentity[models.DeliveryBasic](ctx) } diff --git a/backend/api/internal/logic/platform/gas/gas.go b/backend/api/internal/logic/platform/gas/gas.go index 1959a82..3031935 100644 --- a/backend/api/internal/logic/platform/gas/gas.go +++ b/backend/api/internal/logic/platform/gas/gas.go @@ -1,12 +1,18 @@ package gas import ( + "errors" + "strings" + "time" + "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // ListGasBasic 查询可燃气体站分页列表。 @@ -46,3 +52,78 @@ func UpdateGasBasic(ctx *gin.Context) { } common.UpdateAllowedByIdentity(ctx, &models.GasBasic{}, gin.H{"name": request.Name, "credit_code": request.CreditCode, "principal": request.Principal, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude}, []string{"name", "credit_code", "principal", "address", "longitude", "latitude"}) } + +// UpdateGasBasicStatus 仅允许已审核气站在启用和停用之间切换。 +func UpdateGasBasicStatus(ctx *gin.Context) { + var request struct { + Status int `json:"status" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || + (request.Status != common.StatusEnable && request.Status != common.StatusDisable) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + result := impl.DBService.Model(&models.GasBasic{}). + Where("identity = ? AND status IN ?", ctx.Param("identity"), []int{common.StatusEnable, common.StatusDisable}). + Update("status", request.Status) + if result.Error != nil { + infra.Response.Error(ctx, result.Error) + return + } + if result.RowsAffected != 1 { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) +} + +// ReviewGasBasic 审核待审核气站;不通过时必须填写理由。 +func ReviewGasBasic(ctx *gin.Context) { + var request struct { + Approved bool `json:"approved"` + Reason string `json:"reason" binding:"max=2000"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || + (!request.Approved && strings.TrimSpace(request.Reason) == "") { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + + operatorIdentity, operatorName := common.PlatformOperator(ctx) + targetStatus := common.StatusDisable + reviewStatus := common.StatusRejected + if request.Approved { + targetStatus = common.StatusEnable + reviewStatus = common.StatusApproved + } + now := time.Now() + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var station models.GasBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("identity = ? AND status <> ?", ctx.Param("identity"), common.StatusArchived). + First(&station).Error; err != nil { + return err + } + if station.Status != common.StatusDraft { + return errors.New("gas station is not pending review") + } + if err := tx.Model(&station).Update("status", targetStatus).Error; err != nil { + return err + } + review := models.GasBasicReview{ + Entity: common.NewEntity(common.StatusEnable), + GasBasicID: station.ID, + ReviewStatus: reviewStatus, + ReviewReason: strings.TrimSpace(request.Reason), + ReviewerIdentity: operatorIdentity, + ReviewerName: operatorName, + ReviewedAt: now, + } + return tx.Create(&review).Error + }) + if err != nil { + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": targetStatus}) +} diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index 6b38f1d..af648cf 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -46,6 +46,9 @@ func platformMenuAllowsRequest(menus []platformbase.Menu, requestPath, method st (resource == "delivery_basic" || resource == "staff_account") { return true } + if method == "GET" && menu.Identity == "gas_basic" && resource == "delivery_basic" { + return true + } if method == "GET" && relative == "wallet_basic" && (menu.Identity == "gas_basic" || menu.Identity == "delivery_basic" || menu.Identity == "staff" || menu.Identity == "user_account" || diff --git a/backend/api/internal/logic/platform/platform/access_test.go b/backend/api/internal/logic/platform/platform/access_test.go index 598631d..8a9c405 100644 --- a/backend/api/internal/logic/platform/platform/access_test.go +++ b/backend/api/internal/logic/platform/platform/access_test.go @@ -58,6 +58,16 @@ func TestOwnerMenusCanReadEmbeddedWallets(t *testing.T) { } } +func TestGasManagementCanReadDeliveryPointCounts(t *testing.T) { + menus := []platformbase.Menu{{Identity: "gas_basic"}} + if !platformMenuAllowsRequest(menus, "/heqi/platform/v1/delivery_basic", "GET") { + t.Fatal("gas management could not read its delivery point counts") + } + if platformMenuAllowsRequest(menus, "/heqi/platform/v1/delivery_basic", "POST") { + t.Fatal("gas management unexpectedly received delivery point creation access") + } +} + func TestLocationScopeValuesAreExplicit(t *testing.T) { if !validLocationScope("standard") || !validLocationScope("precise") { t.Fatal("supported location scopes were rejected") diff --git a/backend/api/internal/models/gas_basic_review.go b/backend/api/internal/models/gas_basic_review.go new file mode 100644 index 0000000..04e05ff --- /dev/null +++ b/backend/api/internal/models/gas_basic_review.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "git.apinb.com/bsm-sdk/core/database" +) + +// GasBasicReview 对应 gas_basic_review,保存气站审核记录。 +type GasBasicReview struct { + Entity // 公共实体字段 + GasBasicID uint64 `gorm:"column:gas_basic_id;not null;index" json:"gas_basic_id"` // 气站自增主键 + ReviewStatus int `gorm:"column:review_status;not null;index" json:"review_status"` // 审核结果:已通过或已驳回 + ReviewReason string `gorm:"column:review_reason;type:text;not null;default:''" json:"review_reason"` // 审核不通过理由 + ReviewerIdentity string `gorm:"column:reviewer_identity;type:varchar(36);not null;default:'';index" json:"reviewer_identity"` // 审核人业务标识 + ReviewerName string `gorm:"column:reviewer_name;type:varchar(64);not null;default:''" json:"reviewer_name"` // 审核人姓名快照 + ReviewedAt time.Time `gorm:"column:reviewed_at;type:timestamptz;not null" json:"reviewed_at"` // 审核时间 +} + +func init() { database.AppendMigrate(&GasBasicReview{}) } + +// TableName 返回与模型、文件名一致的单数数据表名。 +func (table *GasBasicReview) TableName() string { return "gas_basic_review" } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 7d3e3dc..47affc4 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -49,7 +49,14 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { } func registerGasRoute(group *gin.RouterGroup) { - registerWritableResource(group, "/gas_basic", gas.ListGasBasic, gas.CreateGasBasic, gas.GetGasBasic, gas.UpdateGasBasic, &models.GasBasic{}) + basic := group.Group("/gas_basic") + basic.GET("", gas.ListGasBasic) + basic.POST("", gas.CreateGasBasic) + basic.GET("/:identity", gas.GetGasBasic) + basic.PUT("/:identity", gas.UpdateGasBasic) + basic.PATCH("/:identity/status", gas.UpdateGasBasicStatus) + basic.POST("/:identity/review", gas.ReviewGasBasic) + basic.DELETE("/:identity", func(ctx *gin.Context) { common.ArchiveRecord(ctx, &models.GasBasic{}) }) registerWritableResource(group, "/gas_account", gas.ListGasAccount, gas.CreateGasAccount, gas.GetGasAccount, gas.UpdateGasAccount, &models.GasAccount{}) } diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 3c64f8b..f357575 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -63,6 +63,18 @@ func TestPlatformGasRouteUsesGasBasic(t *testing.T) { t.Fatal("gas_basic list route is not registered") } +func TestPlatformGasRouteExposesReview(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + for _, route := range engine.Routes() { + if route.Method == http.MethodPost && route.Path == "/heqi/platform/v1/gas_basic/:identity/review" { + return + } + } + t.Fatal("gas_basic review route is not registered") +} + func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { engine := gin.New() RegisterPlatform("heqi", engine) diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 821eb11..221d707 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -3,8 +3,14 @@ @@ -16,7 +22,7 @@