From d4799cd320b2476cf72ce5f54e2d0651bfa7afef Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 14:03:10 +0800 Subject: [PATCH] fix platform workflow integrity and permissions --- backend/api/internal/initdb/platform.go | 2 +- backend/api/internal/initdb/platform_test.go | 4 +- backend/api/internal/logic/common/base.go | 21 +- backend/api/internal/logic/common/resource.go | 80 +++++++- .../internal/logic/common/resource_test.go | 52 +++++ backend/api/internal/logic/common/status.go | 15 -- backend/api/internal/logic/platform/auth.go | 14 +- backend/api/internal/logic/platform/ec/ec.go | 75 ++++++- .../api/internal/logic/platform/ec/ec_test.go | 16 ++ .../api/internal/logic/platform/fin/fin.go | 15 ++ .../internal/logic/platform/fin/fin_test.go | 12 ++ .../logic/platform/gasorder/gasorder.go | 192 +++++++++++++++--- .../logic/platform/gasorder/gasorder_test.go | 68 +++++++ backend/api/internal/logic/platform/menu.go | 25 ++- .../logic/platform/platform/access.go | 43 ++-- .../logic/platform/platform/access_test.go | 30 +++ .../logic/platform/platform/account.go | 12 +- .../logic/platform/platform/role_menu.go | 24 +-- .../logic/platform/product/product.go | 84 +++++--- .../logic/platform/product/product_test.go | 26 +++ .../logic/platform/resource_contract.go | 2 +- .../internal/logic/platform/user/relation.go | 30 ++- .../internal/logic/platform/wallet/wallet.go | 15 +- backend/api/internal/models/ec_cart.go | 8 +- backend/api/internal/models/ec_order.go | 10 +- backend/api/internal/models/ec_order_item.go | 10 +- backend/api/internal/models/ec_product.go | 10 +- backend/api/internal/models/ec_review.go | 10 +- backend/api/internal/models/entity.go | 1 - backend/api/internal/models/fin_payment.go | 8 +- backend/api/internal/models/fin_settlement.go | 10 +- backend/api/internal/models/gasorder_basic.go | 2 +- .../models/gasorder_contract_product.go | 1 + backend/api/internal/models/gasorder_item.go | 15 +- .../api/internal/models/platform_role_menu.go | 8 +- backend/api/internal/models/product_repair.go | 20 +- backend/api/internal/models/user_address.go | 10 +- backend/api/internal/routers/platform.go | 16 +- backend/api/internal/routers/platform_test.go | 3 +- backend/api/internal/seed/mock.go | 7 +- backend/api/internal/seed/mock_test.go | 2 +- frontend/platform_admin/src/api/resources.ts | 9 +- .../src/contracts/platform-resources.json | 2 +- .../src/views/shared/CrudListPage.vue | 2 +- 44 files changed, 797 insertions(+), 224 deletions(-) create mode 100644 backend/api/internal/logic/platform/ec/ec_test.go create mode 100644 backend/api/internal/logic/platform/fin/fin_test.go create mode 100644 backend/api/internal/logic/platform/platform/access_test.go diff --git a/backend/api/internal/initdb/platform.go b/backend/api/internal/initdb/platform.go index 87f885c..4c56aeb 100644 --- a/backend/api/internal/initdb/platform.go +++ b/backend/api/internal/initdb/platform.go @@ -22,7 +22,7 @@ const ( // InitPlatformAccess 幂等初始化 root 角色;菜单定义位于逻辑层静态数据中。 func InitPlatformAccess(database *gorm.DB) error { rootRole := models.PlatformRole{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, RoleCode: PlatformRootRoleCode, Name: "系统管理员", DataScope: "global", diff --git a/backend/api/internal/initdb/platform_test.go b/backend/api/internal/initdb/platform_test.go index 856683a..5d9ee37 100644 --- a/backend/api/internal/initdb/platform_test.go +++ b/backend/api/internal/initdb/platform_test.go @@ -22,8 +22,8 @@ func TestInitPlatformAccessSeedsRootRole(t *testing.T) { 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", 1, 1, "root", "Root", "global", true)) + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "status", "role_code", "name", "data_scope", "is_system"}). + AddRow(uint64(1), "root-role", 1, "root", "Root", "global", true)) if err := InitPlatformAccess(database); err != nil { t.Fatal(err) diff --git a/backend/api/internal/logic/common/base.go b/backend/api/internal/logic/common/base.go index e670cea..a136902 100644 --- a/backend/api/internal/logic/common/base.go +++ b/backend/api/internal/logic/common/base.go @@ -42,7 +42,7 @@ func UpdateRecordStatus(ctx *gin.Context, model any) { var request struct { Status int `json:"status" binding:"required"` } - if err := ctx.ShouldBindJSON(&request); err != nil { + if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -55,7 +55,22 @@ func ArchiveRecord(ctx *gin.Context, model any) { } func NewEntity(status int) models.Entity { - return models.Entity{Identity: models.NewIdentity(), Status: status, Version: 1} + return models.Entity{Identity: models.NewIdentity(), Status: status} +} + +// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle. +func IsGenericRecordStatus(status int) bool { + switch status { + case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen: + return true + default: + return false + } +} + +// ActiveRecords excludes logically archived records from operational queries. +func ActiveRecords(query *gorm.DB) *gorm.DB { + return query.Where("status <> ?", StatusArchived) } func ListPage[T any](ctx *gin.Context) { @@ -63,7 +78,7 @@ func ListPage[T any](ctx *gin.Context) { var list []T var total int64 model := new(T) - databaseQuery := ApplyKeywordFilter(ctx, impl.DBService.Model(model), model) + databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model) if err := databaseQuery.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return diff --git a/backend/api/internal/logic/common/resource.go b/backend/api/internal/logic/common/resource.go index 10b61a3..22aca68 100644 --- a/backend/api/internal/logic/common/resource.go +++ b/backend/api/internal/logic/common/resource.go @@ -37,7 +37,7 @@ 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) + query := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -74,6 +74,10 @@ func createResource(ctx *gin.Context, model any, allowedFields []string, relatio infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if err := ValidateResourceValues(model, values, true); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } encoded, err := json.Marshal(values) if err != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) @@ -123,7 +127,7 @@ func maskCreatedSensitiveFields(value any) any { func isCreatedResponseField(key string) bool { switch key { - case "identity", "status", "version", "created_at", "updated_at": + case "identity", "status", "created_at", "updated_at": return true default: return strings.HasSuffix(key, "_identity") @@ -226,7 +230,7 @@ func updateResource(ctx *gin.Context, model any, allowedFields []string, relatio infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - if len(values) == 0 { + if len(values) == 0 || ValidateResourceValues(model, values, false) != nil { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -245,6 +249,74 @@ func PrepareResourceValues(ctx *gin.Context, model any, allowedFields []string, return values, nil } +// ValidateResourceValues enforces invariants that database nullability and +// frontend form metadata cannot express. +func ValidateResourceValues(model any, values map[string]any, creating bool) error { + positive := func(key string) bool { + value, exists := numericValue(values[key]) + return !exists || value > 0 + } + nonNegative := func(key string) bool { + value, exists := numericValue(values[key]) + return !exists || value >= 0 + } + nonEmpty := func(key string) bool { + value, exists := values[key] + if !exists { + return !creating + } + text, ok := value.(string) + return ok && strings.TrimSpace(text) != "" + } + switch model.(type) { + case *models.EcProduct: + if !nonEmpty("product_code") || !nonEmpty("name") || !nonNegative("price_amount") || !nonNegative("stock_quantity") { + return errors.New("invalid commerce product") + } + case *models.EcCart: + if !positive("quantity") { + return errors.New("invalid cart quantity") + } + case *models.EcOrder: + if !nonEmpty("order_no") || !nonNegative("total_amount") { + return errors.New("invalid order") + } + case *models.EcOrderItem: + if !nonEmpty("product_snapshot") || !positive("quantity") || !nonNegative("sale_amount") { + return errors.New("invalid order item") + } + case *models.EcReview: + score, exists := numericValue(values["score"]) + if (exists && (score < 1 || score > 5)) || !nonEmpty("content") { + return errors.New("invalid review") + } + case *models.FinPayment: + if !positive("amount") || !nonEmpty("channel") { + return errors.New("invalid payment") + } + } + return nil +} + +func numericValue(value any) (float64, bool) { + switch number := value.(type) { + case float64: + return number, true + case float32: + return float64(number), true + case int: + return float64(number), true + case int64: + return float64(number), true + case uint: + return float64(number), true + case uint64: + return float64(number), true + default: + return 0, false + } +} + func ResolveResourceRelations(input map[string]any, allowedFields []string, relations []ResourceRelation, requireRelations bool) (map[string]any, error) { values := FilterFields(input, allowedFields) for _, relation := range relations { @@ -286,7 +358,7 @@ func ResolveIdentityID(model any, identity string, required bool) (uint64, error return 0, nil } var related struct{ ID uint64 } - if err := impl.DBService.Model(model).Select("id").Where("identity = ?", identity).First(&related).Error; err != nil { + if err := impl.DBService.Model(model).Select("id").Where("identity = ? AND status <> ?", identity, StatusArchived).First(&related).Error; err != nil { return 0, err } return related.ID, nil diff --git a/backend/api/internal/logic/common/resource_test.go b/backend/api/internal/logic/common/resource_test.go index 40d855a..f38a16f 100644 --- a/backend/api/internal/logic/common/resource_test.go +++ b/backend/api/internal/logic/common/resource_test.go @@ -2,9 +2,13 @@ package common import ( "net/http" + "strings" "testing" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) { @@ -14,6 +18,24 @@ func TestFilterFieldsKeepsOnlyAllowedKeys(t *testing.T) { } } +func TestOperationalQueriesExcludeArchivedRecords(t *testing.T) { + sqlDatabase, _, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer sqlDatabase.Close() + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + return ActiveRecords(tx.Model(&models.EcProduct{})).Find(&[]models.EcProduct{}) + }) + if !strings.Contains(statement, `status <> 3`) { + t.Fatalf("archive filter missing from operational query: %s", statement) + } +} + func TestResourceResponseStripsInternalIDsRecursively(t *testing.T) { got := ResourceResponse(map[string]any{ "id": uint64(1), "identity": "root", @@ -56,3 +78,33 @@ func TestCommonMethodModesRemainHTTPCompatible(t *testing.T) { t.Fatal("standard HTTP methods unavailable") } } + +func TestGenericStatusRejectsDomainLifecycleValues(t *testing.T) { + for _, status := range []int{StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen} { + if !IsGenericRecordStatus(status) { + t.Fatalf("generic status %d was rejected", status) + } + } + if IsGenericRecordStatus(StatusCompleted) { + t.Fatal("business lifecycle status was accepted as generic entity status") + } +} + +func TestCommerceAndFinanceRejectInvalidAmounts(t *testing.T) { + tests := []struct { + model any + values map[string]any + }{ + {&models.EcProduct{}, map[string]any{"product_code": "p", "name": "P", "price_amount": -1.0}}, + {&models.EcCart{}, map[string]any{"quantity": 0.0}}, + {&models.EcOrder{}, map[string]any{"order_no": "o", "total_amount": -1.0}}, + {&models.EcOrderItem{}, map[string]any{"product_snapshot": "{}", "quantity": -1.0, "sale_amount": 1.0}}, + {&models.EcReview{}, map[string]any{"score": 6.0, "content": "bad"}}, + {&models.FinPayment{}, map[string]any{"channel": "wallet", "amount": -1.0}}, + } + for _, test := range tests { + if ValidateResourceValues(test.model, test.values, true) == nil { + t.Fatalf("%T accepted invalid values %#v", test.model, test.values) + } + } +} diff --git a/backend/api/internal/logic/common/status.go b/backend/api/internal/logic/common/status.go index f88f2d7..eb2b01f 100644 --- a/backend/api/internal/logic/common/status.go +++ b/backend/api/internal/logic/common/status.go @@ -38,18 +38,3 @@ const ( StatusSuccess = 37 // 成功 StatusMatched = 38 // 已匹配 ) - -var statusNames = map[int]string{ - StatusDraft: "draft", StatusEnable: "enabled", StatusDisable: "disabled", StatusArchived: "archived", StatusFrozen: "frozen", - StatusPending: "pending", StatusActive: "active", StatusExpired: "expired", StatusTerminated: "terminated", - StatusRecorded: "recorded", StatusBound: "bound", StatusCreated: "created", StatusOrdered: "ordered", - StatusAssigned: "assigned", StatusFilling: "filling", StatusReady: "ready", StatusException: "exception", - StatusCancelled: "cancelled", StatusCompleted: "completed", StatusPosted: "posted", StatusApproved: "approved", - StatusRejected: "rejected", StatusScrapped: "scrapped", StatusInStock: "in_stock", StatusInTransit: "in_transit", - StatusInUse: "in_use", StatusRepairing: "repairing", StatusOpen: "open", StatusDelivering: "delivering", - StatusAwaitingConfirmation: "awaiting_confirmation", - StatusPaid: "paid", StatusPublished: "published", StatusSuccess: "success", StatusMatched: "matched", -} - -// StatusName 返回状态整数对应的稳定英文名称,供审计快照字段使用。 -func StatusName(status int) string { return statusNames[status] } diff --git a/backend/api/internal/logic/platform/auth.go b/backend/api/internal/logic/platform/auth.go index db286ad..0bf02c8 100644 --- a/backend/api/internal/logic/platform/auth.go +++ b/backend/api/internal/logic/platform/auth.go @@ -58,12 +58,22 @@ func Login(ctx *gin.Context) { return } + extend := map[string]string{"username": account.Username, "display_name": account.DisplayName} + if account.PlatformRoleCode == "root" { + extend["location_scope"] = "precise" + } else { + var role models.PlatformRole + if impl.DBService.Select("data_scope").Where("role_code = ? AND status = ?", account.PlatformRoleCode, common.StatusEnable).First(&role).Error == nil && + role.DataScope == "precise" { + extend["location_scope"] = "precise" + } + } accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt( 0, account.Identity, "platform_admin", account.PlatformRoleCode, - map[string]string{"username": account.Username, "display_name": account.DisplayName}, + extend, nil, ) if err != nil { @@ -99,7 +109,7 @@ func CurrentProfile(ctx *gin.Context) { menuCodes := make([]string, 0, len(menus)) seenMenuCodes := make(map[string]bool, len(menus)) for _, menu := range menus { - codes := []string{menu.MenuCode} + codes := []string{menu.Identity} if path := strings.Trim(menu.Path, "/"); path != "" { codes = append(codes, strings.Split(path, "/")[0]) } diff --git a/backend/api/internal/logic/platform/ec/ec.go b/backend/api/internal/logic/platform/ec/ec.go index f4ce4dc..b358677 100644 --- a/backend/api/internal/logic/platform/ec/ec.go +++ b/backend/api/internal/logic/platform/ec/ec.go @@ -1,6 +1,7 @@ package ec import ( + "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" @@ -8,6 +9,12 @@ import ( "github.com/gin-gonic/gin" ) +type categoryRequest struct { + ParentIdentity string `json:"parent_identity"` + Name string `json:"name" binding:"required,max=128"` + SortNo int `json:"sort_no"` +} + type ecCategoryView struct { Identity string `json:"identity"` ParentIdentity string `json:"parent_identity,omitempty"` @@ -50,11 +57,12 @@ func ListEcCategory(ctx *gin.Context) { page, size := common.PageSize(ctx) var list []models.EcCategory var total int64 - if err := impl.DBService.Model(&models.EcCategory{}).Count(&total).Error; err != nil { + query := common.ActiveRecords(impl.DBService.Model(&models.EcCategory{})) + if err := query.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 { + if err := query.Order("sort_no asc, id asc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { infra.Response.Error(ctx, err) return } @@ -79,3 +87,66 @@ func GetEcCategory(ctx *gin.Context) { } infra.Response.Success(ctx, views[0]) } + +func CreateEcCategory(ctx *gin.Context) { + var request categoryRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + category := models.EcCategory{Entity: common.NewEntity(common.StatusDraft), ParentID: parentID, Name: request.Name, SortNo: request.SortNo} + if err := impl.DBService.Create(&category).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + common.RespondCreatedResource(ctx, category) +} + +func UpdateEcCategory(ctx *gin.Context) { + var request categoryRequest + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var category models.EcCategory + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&category).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + parentID, err := common.ResolveIdentityID(&models.EcCategory{}, request.ParentIdentity, false) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + var rows []models.EcCategory + if err := impl.DBService.Select("id", "parent_id").Find(&rows).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + parents := make(map[uint64]uint64, len(rows)) + for _, row := range rows { + parents[row.ID] = row.ParentID + } + if wouldCreateCategoryCycle(category.ID, parentID, parents) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + common.UpdateAllowedByIdentity(ctx, &models.EcCategory{}, gin.H{"parent_id": parentID, "name": request.Name, "sort_no": request.SortNo}, []string{"parent_id", "name", "sort_no"}) +} + +func wouldCreateCategoryCycle(categoryID, parentID uint64, parents map[uint64]uint64) bool { + seen := map[uint64]bool{} + for parentID != 0 { + if parentID == categoryID || seen[parentID] { + return true + } + seen[parentID] = true + parentID = parents[parentID] + } + return false +} diff --git a/backend/api/internal/logic/platform/ec/ec_test.go b/backend/api/internal/logic/platform/ec/ec_test.go new file mode 100644 index 0000000..e50b47f --- /dev/null +++ b/backend/api/internal/logic/platform/ec/ec_test.go @@ -0,0 +1,16 @@ +package ec + +import "testing" + +func TestCategoryHierarchyRejectsCycles(t *testing.T) { + parents := map[uint64]uint64{2: 1, 3: 2} + if !wouldCreateCategoryCycle(1, 3, parents) { + t.Fatal("moving a category under its descendant did not form a detected cycle") + } + if !wouldCreateCategoryCycle(2, 2, parents) { + t.Fatal("self-parent was accepted") + } + if wouldCreateCategoryCycle(3, 1, parents) { + t.Fatal("valid move to an ancestor was rejected") + } +} diff --git a/backend/api/internal/logic/platform/fin/fin.go b/backend/api/internal/logic/platform/fin/fin.go index d789f27..46c89fe 100644 --- a/backend/api/internal/logic/platform/fin/fin.go +++ b/backend/api/internal/logic/platform/fin/fin.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "time" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -40,6 +41,9 @@ func rewriteSettlementSubject(ctx *gin.Context) error { if err := ctx.ShouldBindJSON(&input); err != nil { return err } + if !validSettlementPeriod(input["period_start"], input["period_end"]) { + return errors.New("invalid settlement period") + } subjectType, _ := input["subject_type"].(string) identity, _ := input["subject_identity"].(string) var model any @@ -66,3 +70,14 @@ func rewriteSettlementSubject(ctx *gin.Context) error { ctx.Request.Body = io.NopCloser(bytes.NewReader(encoded)) return nil } + +func validSettlementPeriod(startValue, endValue any) bool { + startText, startOK := startValue.(string) + endText, endOK := endValue.(string) + if !startOK || !endOK { + return false + } + start, startErr := time.Parse(time.RFC3339, startText) + end, endErr := time.Parse(time.RFC3339, endText) + return startErr == nil && endErr == nil && end.After(start) +} diff --git a/backend/api/internal/logic/platform/fin/fin_test.go b/backend/api/internal/logic/platform/fin/fin_test.go new file mode 100644 index 0000000..8581b46 --- /dev/null +++ b/backend/api/internal/logic/platform/fin/fin_test.go @@ -0,0 +1,12 @@ +package fin + +import "testing" + +func TestSettlementPeriodMustMoveForward(t *testing.T) { + if !validSettlementPeriod("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z") { + t.Fatal("valid settlement period was rejected") + } + if validSettlementPeriod("2026-02-01T00:00:00Z", "2026-01-01T00:00:00Z") { + t.Fatal("reversed settlement period was accepted") + } +} diff --git a/backend/api/internal/logic/platform/gasorder/gasorder.go b/backend/api/internal/logic/platform/gasorder/gasorder.go index ef1eb9b..9a90f6c 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder.go @@ -147,8 +147,12 @@ func CreateGasorderContract(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + if !deliveryBelongsToGas(deliveryID, gasID) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } contract := models.GasorderContract{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusDraft}, ContractNo: request.ContractNo, UserAccountID: userID, GasBasicID: gasID, DeliveryBasicID: deliveryID, Title: request.Title, Terms: request.Terms, FileURI: request.FileURI, DefaultDeliveryFee: request.DefaultDeliveryFee, SignedAt: request.SignedAt, EffectiveAt: request.EffectiveAt, ExpiredAt: request.ExpiredAt, @@ -181,6 +185,12 @@ func UpdateGasorderContract(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + var contract models.GasorderContract + if err := impl.DBService.Select("gas_basic_id").Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil || + !deliveryBelongsToGas(deliveryID, contract.GasBasicID) { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } result := impl.DBService.Model(&models.GasorderContract{}). Where("identity = ? AND status = ?", ctx.Param("identity"), common.StatusDraft). Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms, @@ -280,7 +290,7 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) { func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision { return &models.GasorderContractRevision{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded}, GasorderContractID: contract.ID, Action: action, ContractStatus: contract.Status, EffectiveAt: contract.EffectiveAt, ExpiredAt: contract.ExpiredAt, OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason, @@ -318,7 +328,7 @@ func BindGasorderContractProduct(ctx *gin.Context) { return } binding := models.GasorderContractProduct{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusBound}, GasorderContractID: contract.ID, ProductInfoID: product.ID, ProductCode: product.Code, ProductTypeName: productType.Name, ProductParams: product.Params, UnitPrice: request.UnitPrice, BoundAt: time.Now(), } @@ -330,10 +340,17 @@ func BindGasorderContractProduct(ctx *gin.Context) { } func UnbindGasorderContractProduct(ctx *gin.Context) { + var request struct { + Reason string `json:"reason" binding:"required"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } now := time.Now() result := impl.DBService.Model(&models.GasorderContractProduct{}). Where("identity = ? AND unbound_at IS NULL", ctx.Param("identity")). - Updates(map[string]any{"status": "unbound", "unbound_at": &now}) + Updates(gasorderUnbindUpdates(request.Reason, now)) if result.Error != nil || result.RowsAffected != 1 { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return @@ -404,13 +421,6 @@ func CreateGasorderBasic(ctx *gin.Context) { !product.IsEnabled || product.Status == common.StatusScrapped || product.UserAccountID != contract.UserAccountID { return errors.New("contract product is no longer eligible") } - var activeOrderCount int64 - if err := tx.Table("gasorder_item"). - Joins("JOIN gasorder_basic ON gasorder_basic.id = gasorder_item.gasorder_basic_id"). - Where("gasorder_item.product_info_id = ? AND gasorder_basic.status NOT IN ?", binding.ProductInfoID, []int{common.StatusCompleted, common.StatusCancelled}). - Count(&activeOrderCount).Error; err != nil || activeOrderCount != 0 { - return errors.New("contract product already has an active order") - } productAmount += binding.UnitPrice } payable := productAmount + contract.DefaultDeliveryFee - request.DiscountAmount @@ -418,7 +428,7 @@ func CreateGasorderBasic(ctx *gin.Context) { return errors.New("invalid payable amount") } order = models.GasorderBasic{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusCreated}, OrderNo: models.NewIdentity(), RequestNo: request.RequestNo, GasorderContractID: contract.ID, UserAccountID: contract.UserAccountID, CreatorType: request.CreatorType, CreatorID: creatorID, CreatorIdentity: request.CreatorIdentity, GasBasicID: contract.GasBasicID, DeliveryBasicID: contract.DeliveryBasicID, @@ -433,8 +443,9 @@ func CreateGasorderBasic(ctx *gin.Context) { } for _, binding := range bindings { item := models.GasorderItem{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusOrdered}, GasorderBasicID: order.ID, GasorderContractProductID: binding.ID, ProductInfoID: binding.ProductInfoID, + Active: true, ProductCode: binding.ProductCode, ProductTypeName: binding.ProductTypeName, ProductParams: binding.ProductParams, UnitPrice: binding.UnitPrice, } @@ -445,6 +456,10 @@ func CreateGasorderBasic(ctx *gin.Context) { return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "order created", operatorIdentity, operatorName)).Error }) if err != nil { + if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&order).Error; lookupErr == nil { + common.RespondCreatedResource(ctx, order) + return + } infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -481,6 +496,9 @@ func AssignGasorderBasic(ctx *gin.Context) { if order.Status != common.StatusCreated && order.Status != common.StatusAssigned { return errors.New("order cannot be assigned") } + if !validGasorderAssignment(order, delivery, staff) { + return errors.New("delivery or staff does not belong to the order organization") + } previous := order.Status if err := tx.Model(&order).Updates(map[string]any{ "delivery_basic_id": delivery.ID, "staff_account_id": staff.ID, "status": common.StatusAssigned, @@ -488,7 +506,7 @@ func AssignGasorderBasic(ctx *gin.Context) { return err } assignment := models.GasorderAssign{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded}, GasorderBasicID: order.ID, GasBasicID: order.GasBasicID, DeliveryBasicID: delivery.ID, StaffAccountID: staff.ID, AssignerIdentity: operatorIdentity, AssignerName: operatorName, AssignedAt: time.Now(), Reason: request.Reason, @@ -509,20 +527,68 @@ func AssignGasorderBasic(ctx *gin.Context) { } func GasorderStartFilling(ctx *gin.Context) { - transitionGasorder(ctx, common.StatusFilling, map[int]bool{common.StatusAssigned: true}) + transitionGasorder(ctx, common.StatusFilling) } func GasorderReady(ctx *gin.Context) { - transitionGasorder(ctx, common.StatusReady, map[int]bool{common.StatusFilling: true}) + transitionGasorder(ctx, common.StatusReady) } func GasorderCancel(ctx *gin.Context) { - transitionGasorder(ctx, common.StatusCancelled, map[int]bool{common.StatusCreated: true, common.StatusAssigned: true}) + transitionGasorder(ctx, common.StatusCancelled) +} +func GasorderStartDelivering(ctx *gin.Context) { + transitionGasorder(ctx, common.StatusDelivering) +} +func GasorderAwaitingConfirmation(ctx *gin.Context) { + transitionGasorder(ctx, common.StatusAwaitingConfirmation) +} + +func GasorderComplete(ctx *gin.Context) { + var request struct { + Reason string `json:"reason" binding:"required"` + ConfirmType string `json:"confirm_type" binding:"required,max=32"` + RecipientName string `json:"recipient_name" binding:"required,max=64"` + RecipientPhone string `json:"recipient_phone" binding:"max=32"` + ProofURI string `json:"proof_uri" binding:"max=512"` + Remark string `json:"remark"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + operatorIdentity, operatorName := common.PlatformOperator(ctx) + err := impl.DBService.Transaction(func(tx *gorm.DB) error { + var order models.GasorderBasic + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + return err + } + if order.Status != common.StatusAwaitingConfirmation { + return errors.New("order cannot be completed") + } + if err := tx.Model(&order).Update("status", common.StatusCompleted).Error; err != nil { + return err + } + confirm := models.GasorderConfirm{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded}, + GasorderBasicID: order.ID, ConfirmType: request.ConfirmType, RecipientName: request.RecipientName, + RecipientPhone: request.RecipientPhone, ProofURI: request.ProofURI, ConfirmedAt: time.Now(), Remark: request.Remark, + } + if err := tx.Create(&confirm).Error; err != nil { + return err + } + if err := releaseGasorderProducts(tx, order.ID); err != nil { + return err + } + return tx.Create(gasorderStatusRecord(order.ID, order.Status, common.StatusCompleted, request.Reason, operatorIdentity, operatorName)).Error + }) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + infra.Response.Success(ctx, gin.H{"updated": true, "status": common.StatusCompleted}) } func GasorderException(ctx *gin.Context) { - transitionGasorder(ctx, common.StatusException, map[int]bool{ - common.StatusFilling: true, common.StatusReady: true, - common.StatusDelivering: true, common.StatusAwaitingConfirmation: true, - }) + transitionGasorder(ctx, common.StatusException) } func GasorderRecover(ctx *gin.Context) { @@ -555,7 +621,7 @@ func GasorderRecover(ctx *gin.Context) { infra.Response.Success(ctx, gin.H{"updated": true}) } -func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) { +func transitionGasorder(ctx *gin.Context, target int) { var request struct { Reason string `json:"reason" binding:"required"` } @@ -569,7 +635,7 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) { if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { return err } - if !allowed[order.Status] || (target == common.StatusFilling && order.DeliveryBasicID == 0) { + if !gasorderTransitionAllowed(order.Status, target) || (target == common.StatusFilling && order.DeliveryBasicID == 0) { return errors.New("invalid order transition") } updates := map[string]any{"status": target} @@ -579,6 +645,35 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) { if err := tx.Model(&order).Updates(updates).Error; err != nil { return err } + if target == common.StatusDelivering { + var attempt int + if err := tx.Model(&models.GasorderTrack{}).Where("gasorder_basic_id = ?", order.ID). + Select("COALESCE(MAX(attempt_no), 0)").Scan(&attempt).Error; err != nil { + return err + } + track := models.GasorderTrack{ + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusActive}, + GasorderBasicID: order.ID, StaffAccountID: order.StaffAccountID, + AttemptNo: attempt + 1, StartedAt: time.Now(), + } + if err := tx.Create(&track).Error; err != nil { + return err + } + } + if target == common.StatusAwaitingConfirmation { + now := time.Now() + result := tx.Model(&models.GasorderTrack{}). + Where("gasorder_basic_id = ? AND completed_at IS NULL", order.ID). + Update("completed_at", &now) + if result.Error != nil || result.RowsAffected != 1 { + return errors.New("active delivery track not found") + } + } + if target == common.StatusCancelled { + if err := releaseGasorderProducts(tx, order.ID); err != nil { + return err + } + } return tx.Create(gasorderStatusRecord(order.ID, order.Status, target, request.Reason, operatorIdentity, operatorName)).Error }) if err != nil { @@ -588,9 +683,58 @@ func transitionGasorder(ctx *gin.Context, target int, allowed map[int]bool) { infra.Response.Success(ctx, gin.H{"updated": true, "status": target}) } +func gasorderTransitionAllowed(from, target int) bool { + switch target { + case common.StatusFilling: + return from == common.StatusAssigned + case common.StatusReady: + return from == common.StatusFilling + case common.StatusDelivering: + return from == common.StatusReady + case common.StatusAwaitingConfirmation: + return from == common.StatusDelivering + case common.StatusCompleted: + return from == common.StatusAwaitingConfirmation + case common.StatusCancelled: + return from == common.StatusCreated || from == common.StatusAssigned + case common.StatusException: + return from == common.StatusFilling || from == common.StatusReady || + from == common.StatusDelivering || from == common.StatusAwaitingConfirmation + default: + return false + } +} + +func gasorderUnbindUpdates(reason string, now time.Time) map[string]any { + return map[string]any{"status": common.StatusDisable, "unbound_at": &now, "unbind_reason": strings.TrimSpace(reason)} +} + +func releaseGasorderProducts(tx *gorm.DB, orderID uint64) error { + return tx.Model(&models.GasorderItem{}).Where("gasorder_basic_id = ?", orderID).Update("active", false).Error +} + +func validGasorderAssignment(order models.GasorderBasic, delivery models.DeliveryBasic, staff models.StaffAccount) bool { + if delivery.GasBasicID != 0 && delivery.GasBasicID != order.GasBasicID { + return false + } + if staff.DeliveryBasicID != delivery.ID { + return false + } + return staff.GasBasicID == 0 || staff.GasBasicID == order.GasBasicID +} + +func deliveryBelongsToGas(deliveryID, gasID uint64) bool { + if deliveryID == 0 { + return true + } + var delivery models.DeliveryBasic + return impl.DBService.Select("gas_basic_id").First(&delivery, deliveryID).Error == nil && + (delivery.GasBasicID == 0 || delivery.GasBasicID == gasID) +} + func gasorderStatusRecord(orderID uint64, from, to int, reason, operatorIdentity, operatorName string) *models.GasorderStatus { return &models.GasorderStatus{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded}, GasorderBasicID: orderID, FromStatus: from, ToStatus: to, OperatorIdentity: operatorIdentity, OperatorName: operatorName, OccurredAt: time.Now(), Reason: reason, } diff --git a/backend/api/internal/logic/platform/gasorder/gasorder_test.go b/backend/api/internal/logic/platform/gasorder/gasorder_test.go index ea5e1e9..4c05a82 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder_test.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder_test.go @@ -1,7 +1,10 @@ package gasorder import ( + "reflect" + "strings" "testing" + "time" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" @@ -18,6 +21,71 @@ func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) { } } +func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) { + updates := gasorderUnbindUpdates(" contract ended ", time.Now()) + status, ok := updates["status"].(int) + if !ok || status != common.StatusDisable { + t.Fatalf("unbind status = %#v, want integer disabled status", updates["status"]) + } + if updates["unbind_reason"] != "contract ended" { + t.Fatalf("unbind reason was not retained: %#v", updates) + } +} + +func TestGasorderCompleteStateMachine(t *testing.T) { + path := []int{ + common.StatusCreated, + common.StatusAssigned, + common.StatusFilling, + common.StatusReady, + common.StatusDelivering, + common.StatusAwaitingConfirmation, + common.StatusCompleted, + } + for index := 1; index < len(path); index++ { + if index == 1 { + continue // assignment has organization validation and its own handler + } + if !gasorderTransitionAllowed(path[index-1], path[index]) { + t.Fatalf("transition %d -> %d is not reachable", path[index-1], path[index]) + } + } + if gasorderTransitionAllowed(common.StatusReady, common.StatusCompleted) { + t.Fatal("state machine allows skipping delivery and confirmation") + } +} + +func TestGasorderAssignmentRequiresSameOrganization(t *testing.T) { + order := models.GasorderBasic{Entity: models.Entity{ID: 1}, GasBasicID: 10} + delivery := models.DeliveryBasic{Entity: models.Entity{ID: 20}, GasBasicID: 10} + staff := models.StaffAccount{GasBasicID: 10, DeliveryBasicID: 20} + if !validGasorderAssignment(order, delivery, staff) { + t.Fatal("valid organization assignment was rejected") + } + staff.DeliveryBasicID = 21 + if validGasorderAssignment(order, delivery, staff) { + t.Fatal("cross-delivery staff assignment was accepted") + } + staff.DeliveryBasicID = 20 + delivery.GasBasicID = 11 + if validGasorderAssignment(order, delivery, staff) { + t.Fatal("cross-gas delivery assignment was accepted") + } +} + +func TestGasorderProductHasConcurrentActiveReservationConstraint(t *testing.T) { + field, ok := reflect.TypeOf(models.GasorderItem{}).FieldByName("ProductInfoID") + if !ok { + t.Fatal("ProductInfoID field missing") + } + tag := field.Tag.Get("gorm") + for _, required := range []string{"uniqueIndex:idx_active_gasorder_product", "where:active = true"} { + if !strings.Contains(tag, required) { + t.Fatalf("active reservation constraint missing %q from %q", required, tag) + } + } +} + func TestGasorderStatusRecordIsImmutableSnapshot(t *testing.T) { record := gasorderStatusRecord(7, common.StatusAssigned, common.StatusFilling, "start filling", "operator-a", "Operator") if record.GasorderBasicID != 7 || record.FromStatus != common.StatusAssigned || record.ToStatus != common.StatusFilling { diff --git a/backend/api/internal/logic/platform/menu.go b/backend/api/internal/logic/platform/menu.go index 760a005..2f3e4b2 100644 --- a/backend/api/internal/logic/platform/menu.go +++ b/backend/api/internal/logic/platform/menu.go @@ -53,10 +53,10 @@ var PlatformMenus = [][]Menu{ {Identity: "product_info", ParentIdentity: "device", MenuCode: "device", Name: "产品信息", Path: "/product/product-info", SortNo: 3, Status: common.StatusEnable}, }, { - {Identity: "gasorder", MenuCode: "delivery", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable}, - {Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable}, - {Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable}, - {Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "delivery", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable}, + {Identity: "gasorder", MenuCode: "gasorder", Name: "气体配送订单管理", Icon: "icon-list", Path: "/gasorder", SortNo: 70, Status: common.StatusEnable}, + {Identity: "gasorder_contract", ParentIdentity: "gasorder", MenuCode: "gasorder_contract", Name: "合同管理", Path: "/gasorder/contracts", SortNo: 1, Status: common.StatusEnable}, + {Identity: "gasorder_basic", ParentIdentity: "gasorder", MenuCode: "gasorder_basic", Name: "配送订单", Path: "/gasorder/orders", SortNo: 2, Status: common.StatusEnable}, + {Identity: "gasorder_track", ParentIdentity: "gasorder", MenuCode: "gasorder_track", Name: "运行轨迹", Path: "/gasorder/tracks", SortNo: 3, Status: common.StatusEnable}, }, { {Identity: "ec", MenuCode: "ec", Name: "商城管理", Icon: "icon-gift", Path: "/ec", SortNo: 80, Status: common.StatusEnable}, @@ -127,19 +127,24 @@ func LoadPlatformMenus(roleCode string) ([]Menu, error) { if err := impl.DBService.Where("role_code = ? AND status = ?", roleCode, common.StatusEnable).First(&role).Error; err != nil { return nil, err } - var codes []string + var identities []string if err := impl.DBService.Model(&models.PlatformRoleMenu{}). Where("platform_role_id = ?", role.ID). - Pluck("menu_code", &codes).Error; err != nil { + Pluck("menu_identity", &identities).Error; err != nil { return nil, err } - allowed := make(map[string]struct{}, len(codes)) - for _, code := range codes { - allowed[code] = struct{}{} + allowed := make(map[string]struct{}, len(identities)) + for _, identity := range identities { + allowed[identity] = struct{}{} + } + for _, menu := range menus { + if _, ok := allowed[menu.Identity]; ok && menu.ParentIdentity != "" { + allowed[menu.ParentIdentity] = struct{}{} + } } filtered := make([]Menu, 0, len(menus)) for _, menu := range menus { - if _, ok := allowed[menu.MenuCode]; ok { + if _, ok := allowed[menu.Identity]; ok { filtered = append(filtered, menu) } } diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index 9936eeb..4508a94 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -19,36 +19,35 @@ func platformMenuAllowsPath(menus []platformbase.Menu, requestPath string) bool } relative := strings.Trim(requestPath[index+len(marker):], "/") resource := strings.Split(relative, "/")[0] - domain := platformRouteDomain(resource) + menuIdentity := platformRouteMenuIdentity(resource) for _, menu := range menus { - if menu.MenuCode == domain { - return true - } - menuPath := strings.Trim(menu.Path, "/") - if menuPath != "" && strings.Split(menuPath, "/")[0] == domain { + if menu.Identity == menuIdentity { return true } } return false } -func platformRouteDomain(resource string) string { - prefix := strings.Split(resource, "_")[0] - switch prefix { - case "product": - return "device" - case "gasorder": - return "delivery" - case "fin": - return "finance" - case "cms": - return "content" - case "cs": - return "customer_service" - case "platform": - return "platform" +func platformRouteMenuIdentity(resource string) string { + switch { + case resource == "dashboard": + return "dashboard_overview" + case strings.HasPrefix(resource, "gasorder_contract"): + return "gasorder_contract" + case resource == "gasorder_track" || resource == "gasorder_track_point": + return "gasorder_track" + case strings.HasPrefix(resource, "gasorder_"): + return "gasorder_basic" + case resource == "product_type" || resource == "product_warehouse": + return resource + case strings.HasPrefix(resource, "product_"): + return "product_info" + case strings.HasPrefix(resource, "cms_"): + return "cms_content" + case strings.HasPrefix(resource, "cs_"): + return "cs_ticket" default: - return prefix + return resource } } diff --git a/backend/api/internal/logic/platform/platform/access_test.go b/backend/api/internal/logic/platform/platform/access_test.go new file mode 100644 index 0000000..c11bca6 --- /dev/null +++ b/backend/api/internal/logic/platform/platform/access_test.go @@ -0,0 +1,30 @@ +package platform + +import ( + "testing" + + platformbase "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform" +) + +func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) { + menus := []platformbase.Menu{{Identity: "delivery_basic"}} + if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") { + t.Fatal("selected second-level menu did not grant its resource") + } + if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") { + t.Fatal("selected second-level menu granted a sibling resource") + } + if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") { + t.Fatal("delivery permission leaked into gasorder") + } +} + +func TestHiddenGasorderResourcesFollowOwningSecondLevelMenu(t *testing.T) { + orderMenus := []platformbase.Menu{{Identity: "gasorder_basic"}} + if !platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_confirm") { + t.Fatal("order confirmation was not covered by order menu") + } + if platformMenuAllowsPath(orderMenus, "/heqi/platform/v1/gasorder_contract") { + t.Fatal("order menu granted contract management") + } +} diff --git a/backend/api/internal/logic/platform/platform/account.go b/backend/api/internal/logic/platform/platform/account.go index 14da664..3f30ea7 100644 --- a/backend/api/internal/logic/platform/platform/account.go +++ b/backend/api/internal/logic/platform/platform/account.go @@ -3,6 +3,7 @@ package platform 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/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" @@ -15,7 +16,7 @@ func ListPlatformAccount(ctx *gin.Context) { page, size := common.PageSize(ctx) var list []models.PlatformAccount var total int64 - query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.PlatformAccount{}), &models.PlatformAccount{}) + query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.PlatformAccount{})), &models.PlatformAccount{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -98,6 +99,15 @@ func UpdatePlatformAccount(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + if claims.Role != "root" && claims.Identity != ctx.Param("identity") { + infra.Response.Error(ctx, errcode.ErrPermissionDenied) + return + } values := gin.H{"display_name": request.DisplayName, "avatar": request.Avatar, "phone": request.Phone} if request.PlatformRoleCode != nil { if !common.RequirePlatformRoot(ctx) { diff --git a/backend/api/internal/logic/platform/platform/role_menu.go b/backend/api/internal/logic/platform/platform/role_menu.go index b70e525..217739e 100644 --- a/backend/api/internal/logic/platform/platform/role_menu.go +++ b/backend/api/internal/logic/platform/platform/role_menu.go @@ -36,19 +36,21 @@ func ReplacePlatformRoleMenus(ctx *gin.Context) { if role.IsSystem { return errSystemPlatformRole } - menuCodes := make(map[string]struct{}, len(request.MenuIdentities)) + menuIdentities := make(map[string]struct{}, len(request.MenuIdentities)) for _, identity := range request.MenuIdentities { menu, ok := platformbase.FindPlatformMenu(identity) if !ok { return gorm.ErrRecordNotFound } - menuCodes[menu.MenuCode] = struct{}{} + if menu.ParentIdentity != "" { + menuIdentities[menu.Identity] = struct{}{} + } } if err := transaction.Where("platform_role_id = ?", role.ID).Delete(&models.PlatformRoleMenu{}).Error; err != nil { return err } - for menuCode := range menuCodes { - relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuCode: menuCode} + for menuIdentity := range menuIdentities { + relation := models.PlatformRoleMenu{PlatformRoleID: role.ID, MenuIdentity: menuIdentity} if err := transaction.Create(&relation).Error; err != nil { return err } @@ -79,22 +81,12 @@ func ListPlatformRoleMenuIdentities(ctx *gin.Context) { common.RespondRecordError(ctx, err) return } - var codes []string + var identities []string if err := impl.DBService.Model(&models.PlatformRoleMenu{}). Where("platform_role_id = ?", role.ID). - Pluck("menu_code", &codes).Error; err != nil { + Pluck("menu_identity", &identities).Error; err != nil { infra.Response.Error(ctx, err) return } - assigned := make(map[string]struct{}, len(codes)) - for _, code := range codes { - assigned[code] = struct{}{} - } - identities := make([]string, 0, len(codes)) - for _, menu := range platformbase.AllPlatformMenus() { - if _, ok := assigned[menu.MenuCode]; ok { - identities = append(identities, menu.Identity) - } - } infra.Response.Success(ctx, gin.H{"menu_identities": identities}) } diff --git a/backend/api/internal/logic/platform/product/product.go b/backend/api/internal/logic/platform/product/product.go index 58aef34..6b4a94e 100644 --- a/backend/api/internal/logic/platform/product/product.go +++ b/backend/api/internal/logic/platform/product/product.go @@ -14,6 +14,7 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" + "gorm.io/gorm/clause" ) var productOwnerActions = map[string]bool{ @@ -43,7 +44,7 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res delete(values, "reason") delete(values, "remark") data := models.ProductInfo{Entity: common.NewEntity(common.StatusPending), Params: "{}", IsEnabled: false} - if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() { + if err := decodeValues(values, &data); err != nil || data.Code == "" || data.Name == "" || data.ProducedAt.IsZero() || !validProductOwnership(data) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } @@ -67,6 +68,8 @@ func createProductInfo(ctx *gin.Context, fields []string, relations []common.Res common.RespondCreatedResource(ctx, &data) } +func canStartProductRepair(pendingCount int64) bool { return pendingCount == 0 } + func updateProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { var input map[string]any if err := ctx.ShouldBindJSON(&input); err != nil { @@ -114,6 +117,13 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res } } } + if enabled, ok := values["is_enabled"].(bool); ok && !enabled { + values["status"] = common.StatusPending + } + preview := current + if err := decodeValues(values, &preview); err != nil || !validProductOwnership(preview) { + return errors.New("product can have at most one current owner") + } ownershipChanged := ownershipValuesChanged(current, values) if ownershipChanged && !productOwnerActions[action] { return errors.New("invalid ownership action") @@ -136,18 +146,15 @@ func updateProductInfo(ctx *gin.Context, fields []string, relations []common.Res infra.Response.Success(ctx, gin.H{"updated": true}) } -func ProductOwnerHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { - fields := []string{"action", "occurred_at", "reason", "remark"} - return listProductOwners, - func(ctx *gin.Context) { createProductOwner(ctx, fields, relations) }, - func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) } +func ProductOwnerHandlers() (gin.HandlerFunc, gin.HandlerFunc) { + return listProductOwners, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductOwner{}) } } func listProductOwners(ctx *gin.Context) { page, size := common.PageSize(ctx) var list []models.ProductOwner var total int64 - query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(&models.ProductOwner{}), &models.ProductOwner{}) + query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(&models.ProductOwner{})), &models.ProductOwner{}) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -172,7 +179,11 @@ func UpdateProductInfoStatus(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, gin.H{"status": request.Status}, []string{"status"}) + values := gin.H{"status": request.Status} + if request.Status == common.StatusScrapped { + values["is_enabled"] = false + } + common.UpdateAllowedByIdentity(ctx, &models.ProductInfo{}, values, []string{"status", "is_enabled"}) } func ProductRepairHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { @@ -195,6 +206,14 @@ func createProductRepair(ctx *gin.Context, fields []string, relations []common.R return } err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var product models.ProductInfo + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, data.ProductInfoID).Error; err != nil { + return err + } + var pending int64 + if err := tx.Model(&models.ProductRepair{}).Where("product_info_id = ? AND result = ?", data.ProductInfoID, "pending").Count(&pending).Error; err != nil || !canStartProductRepair(pending) { + return errors.New("product already has a pending repair") + } if err := tx.Create(&data).Error; err != nil { return err } @@ -239,6 +258,19 @@ func updateProductRepair(ctx *gin.Context, fields []string, relations []common.R return err } if current.Result == "pending" && preview.Result != "pending" { + var product models.ProductInfo + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, preview.ProductInfoID).Error; err != nil { + return err + } + var remaining int64 + if err := tx.Model(&models.ProductRepair{}). + Where("product_info_id = ? AND result = ? AND id <> ?", preview.ProductInfoID, "pending", current.ID). + Count(&remaining).Error; err != nil { + return err + } + if remaining != 0 { + return nil + } return tx.Model(&models.ProductInfo{}).Where("id = ?", preview.ProductInfoID).Update("status", preview.TargetStatus).Error } return nil @@ -265,30 +297,6 @@ func validRepair(repair models.ProductRepair) bool { } } -func createProductOwner(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { - values, err := common.PrepareResourceValues(ctx, &models.ProductOwner{}, fields, relations) - if err != nil { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - action, _ := values["action"].(string) - if action != "manual" { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - data := models.ProductOwner{Entity: common.NewEntity(common.StatusRecorded)} - if err := decodeValues(values, &data); err != nil || data.ProductInfoID == 0 || data.OccurredAt.IsZero() { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) - return - } - data.OperatorIdentity, data.OperatorName = productOperator(ctx) - if err := impl.DBService.Create(&data).Error; err != nil { - infra.Response.Error(ctx, err) - return - } - common.RespondCreatedResource(ctx, &data) -} - func validParamsText(value any) bool { if value == nil { return true @@ -319,6 +327,16 @@ func initialProductStatus(product models.ProductInfo) int { return common.StatusPending } +func validProductOwnership(product models.ProductInfo) bool { + count := 0 + for _, id := range []uint64{product.WarehouseID, product.GasBasicID, product.DeliveryBasicID, product.UserAccountID} { + if id != 0 { + count++ + } + } + return count <= 1 +} + func ownershipValuesChanged(current models.ProductInfo, values map[string]any) bool { for key, old := range map[string]uint64{ "warehouse_id": current.WarehouseID, "gas_basic_id": current.GasBasicID, @@ -357,7 +375,7 @@ func intValue(value any) (int, bool) { func newProductOwner(product models.ProductInfo, action, reason, remark, operatorIdentity, operatorName string, occurredAt time.Time) *models.ProductOwner { return &models.ProductOwner{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusRecorded}, ProductInfoID: product.ID, WarehouseID: product.WarehouseID, GasBasicID: product.GasBasicID, DeliveryBasicID: product.DeliveryBasicID, UserAccountID: product.UserAccountID, Action: action, OccurredAt: occurredAt, Reason: reason, Remark: remark, diff --git a/backend/api/internal/logic/platform/product/product_test.go b/backend/api/internal/logic/platform/product/product_test.go index e49712c..929fe84 100644 --- a/backend/api/internal/logic/platform/product/product_test.go +++ b/backend/api/internal/logic/platform/product/product_test.go @@ -1,6 +1,8 @@ package product import ( + "reflect" + "strings" "testing" "time" @@ -21,6 +23,21 @@ func TestValidParamsTextRequiresJSONObjectText(t *testing.T) { } } +func TestOnlyOnePendingRepairCanStart(t *testing.T) { + if !canStartProductRepair(0) { + t.Fatal("first pending repair was rejected") + } + if canStartProductRepair(1) { + t.Fatal("second concurrent pending repair was accepted") + } + field, _ := reflect.TypeOf(models.ProductRepair{}).FieldByName("ProductInfoID") + tag := field.Tag.Get("gorm") + if !strings.Contains(tag, "uniqueIndex:idx_pending_product_repair") || + !strings.Contains(tag, "where:result = 'pending'") { + t.Fatalf("database pending-repair constraint missing: %q", tag) + } +} + func TestInitialProductStatusPrefersActualWarehouse(t *testing.T) { if got := initialProductStatus(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}); got != common.StatusInStock { t.Fatalf("warehouse product status = %d", got) @@ -59,3 +76,12 @@ func TestOwnershipValuesChangedOnlyForActualDifference(t *testing.T) { t.Fatal("changed ownership was not detected") } } + +func TestProductHasAtMostOneCurrentOwner(t *testing.T) { + if !validProductOwnership(models.ProductInfo{WarehouseID: 1}) { + t.Fatal("single warehouse owner was rejected") + } + if validProductOwnership(models.ProductInfo{WarehouseID: 1, UserAccountID: 2}) { + t.Fatal("conflicting warehouse and user owners were accepted") + } +} diff --git a/backend/api/internal/logic/platform/resource_contract.go b/backend/api/internal/logic/platform/resource_contract.go index 6e39a3c..d063833 100644 --- a/backend/api/internal/logic/platform/resource_contract.go +++ b/backend/api/internal/logic/platform/resource_contract.go @@ -81,7 +81,7 @@ func ExpectedResources() []ResourceContract { 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("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", AppendOnly, "list"), + resourceContract("product", "product_type", Editable, "list"), resourceContract("product", "product_warehouse", Editable, "list"), resourceContract("product", "product_info", Editable, "list"), resourceContract("product", "product_repair", Editable, "list"), resourceContract("product", "product_owner", ReadOnly, "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("gasorder", "gasorder_contract", Managed, "list"), resourceContract("gasorder", "gasorder_contract_product", AppendOnly, "list"), resourceContract("gasorder", "gasorder_contract_revision", ReadOnly, "list"), resourceContract("gasorder", "gasorder_basic", AppendOnly, "list"), resourceContract("gasorder", "gasorder_item", ReadOnly, "list"), resourceContract("gasorder", "gasorder_assign", ReadOnly, "list"), resourceContract("gasorder", "gasorder_status", ReadOnly, "list"), diff --git a/backend/api/internal/logic/platform/user/relation.go b/backend/api/internal/logic/platform/user/relation.go index 13b0016..d85d01a 100644 --- a/backend/api/internal/logic/platform/user/relation.go +++ b/backend/api/internal/logic/platform/user/relation.go @@ -7,6 +7,7 @@ import ( "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" ) type addressRequest struct { @@ -31,7 +32,14 @@ func CreateUserAddress(ctx *gin.Context) { return } address := models.UserAddress{Entity: common.NewEntity(common.StatusEnable), UserAccountID: userAccountID, Address: request.Address, Longitude: request.Longitude, Latitude: request.Latitude, IsDefault: request.IsDefault} - if err := impl.DBService.Create(&address).Error; err != nil { + if err := impl.DBService.Transaction(func(tx *gorm.DB) error { + if request.IsDefault { + if err := tx.Model(&models.UserAddress{}).Where("user_account_id = ?", userAccountID).Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Create(&address).Error + }); err != nil { infra.Response.Error(ctx, err) return } @@ -48,7 +56,25 @@ func UpdateUserAddress(ctx *gin.Context) { infra.Response.Error(ctx, errcode.ErrInvalidArgument) return } - common.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"}) + err = impl.DBService.Transaction(func(tx *gorm.DB) error { + var current models.UserAddress + if err := tx.Where("identity = ?", ctx.Param("identity")).First(¤t).Error; err != nil { + return err + } + if request.IsDefault { + if err := tx.Model(&models.UserAddress{}). + Where("user_account_id = ? AND id <> ?", userAccountID, current.ID). + Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Model(¤t).Updates(gin.H{"user_account_id": userAccountID, "address": request.Address, "longitude": request.Longitude, "latitude": request.Latitude, "is_default": request.IsDefault}).Error + }) + if err != nil { + common.RespondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"updated": true}) } type serviceRelationRequest struct { diff --git a/backend/api/internal/logic/platform/wallet/wallet.go b/backend/api/internal/logic/platform/wallet/wallet.go index 9387a42..a0f52b7 100644 --- a/backend/api/internal/logic/platform/wallet/wallet.go +++ b/backend/api/internal/logic/platform/wallet/wallet.go @@ -43,7 +43,7 @@ func listWalletPage[T any](ctx *gin.Context) { var list []T var total int64 model := new(T) - query := common.ApplyKeywordFilter(ctx, impl.DBService.Model(model), model) + query := common.ApplyKeywordFilter(ctx, common.ActiveRecords(impl.DBService.Model(model)), model) if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return @@ -133,7 +133,7 @@ func GetOrCreateOwnerWallet(ctx *gin.Context) { return err } candidate := models.WalletBasic{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, OwnerType: ownerType, OwnerID: ownerID, OwnerIdentity: ownerIdentity, } if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&candidate).Error; err != nil { @@ -227,7 +227,7 @@ func RechargeWalletBasic(ctx *gin.Context) { } now := time.Now() record = models.WalletRecord{ - Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted, Version: 1}, + Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusPosted}, WalletBasicID: wallet.ID, RecordNo: models.NewIdentity(), RequestNo: request.RequestNo, Direction: "income", TradeType: "recharge", Amount: request.Amount, BalanceAfter: wallet.Balance, WithdrawalBalanceAfter: wallet.WithdrawalBalance, @@ -239,6 +239,15 @@ func RechargeWalletBasic(ctx *gin.Context) { return tx.Create(&record).Error }) if err != nil { + if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&record).Error; lookupErr == nil { + response, responseErr := common.PublicResourceResponse(record) + if responseErr != nil { + infra.Response.Error(ctx, responseErr) + return + } + infra.Response.Success(ctx, response) + return + } common.RespondRecordError(ctx, err) return } diff --git a/backend/api/internal/models/ec_cart.go b/backend/api/internal/models/ec_cart.go index 067ead7..72120d4 100644 --- a/backend/api/internal/models/ec_cart.go +++ b/backend/api/internal/models/ec_cart.go @@ -5,10 +5,10 @@ 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"` // 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 业务字段 + 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;check:quantity > 0" json:"quantity"` // quantity 业务字段 + Selected bool `gorm:"column:selected;not null;default:true" json:"selected"` // selected 业务字段 } func init() { database.AppendMigrate(&EcCart{}) } diff --git a/backend/api/internal/models/ec_order.go b/backend/api/internal/models/ec_order.go index 734ab8e..bf64cb6 100644 --- a/backend/api/internal/models/ec_order.go +++ b/backend/api/internal/models/ec_order.go @@ -5,11 +5,11 @@ 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"` // 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 业务字段 + 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;check:total_amount >= 0" json:"total_amount"` // total_amount 业务字段 } func init() { database.AppendMigrate(&EcOrder{}) } diff --git a/backend/api/internal/models/ec_order_item.go b/backend/api/internal/models/ec_order_item.go index cc715e6..15611f2 100644 --- a/backend/api/internal/models/ec_order_item.go +++ b/backend/api/internal/models/ec_order_item.go @@ -5,11 +5,11 @@ 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"` // 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:text;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 业务字段 + 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:text;not null;default:''" json:"product_snapshot"` // product_snapshot 业务字段 + Quantity int `gorm:"column:quantity;not null;default:1;check:quantity > 0" json:"quantity"` // quantity 业务字段 + SaleAmount int64 `gorm:"column:sale_amount;not null;default:0;check:sale_amount >= 0" json:"sale_amount"` // sale_amount 业务字段 } func init() { database.AppendMigrate(&EcOrderItem{}) } diff --git a/backend/api/internal/models/ec_product.go b/backend/api/internal/models/ec_product.go index 518a219..505ed55 100644 --- a/backend/api/internal/models/ec_product.go +++ b/backend/api/internal/models/ec_product.go @@ -5,11 +5,11 @@ 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"` // 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 业务字段 + 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;check:price_amount >= 0" json:"price_amount"` // price_amount 业务字段 + StockQuantity int `gorm:"column:stock_quantity;not null;default:0;check:stock_quantity >= 0" json:"stock_quantity"` // stock_quantity 业务字段 } func init() { database.AppendMigrate(&EcProduct{}) } diff --git a/backend/api/internal/models/ec_review.go b/backend/api/internal/models/ec_review.go index ff5e932..78ba9df 100644 --- a/backend/api/internal/models/ec_review.go +++ b/backend/api/internal/models/ec_review.go @@ -5,11 +5,11 @@ 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"` // 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 业务字段 + 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;check:score >= 1 AND score <= 5" json:"score"` // score 业务字段 + Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // content 业务字段 } func init() { database.AppendMigrate(&EcReview{}) } diff --git a/backend/api/internal/models/entity.go b/backend/api/internal/models/entity.go index 476309b..9ae8325 100644 --- a/backend/api/internal/models/entity.go +++ b/backend/api/internal/models/entity.go @@ -14,7 +14,6 @@ type Entity struct { CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间 UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null" json:"updated_at"` // 更新时间 Status int `gorm:"column:status;not null;default:0" json:"status"` // 业务状态 - Version int `gorm:"column:version;not null;default:1" json:"version"` // 乐观锁版本 } // NewIdentity 生成时间有序的 UUID V7 字符串,生成失败属于不可恢复的运行时错误。 diff --git a/backend/api/internal/models/fin_payment.go b/backend/api/internal/models/fin_payment.go index b4df3ca..167b93c 100644 --- a/backend/api/internal/models/fin_payment.go +++ b/backend/api/internal/models/fin_payment.go @@ -8,10 +8,10 @@ import ( // FinPayment 对应 fin_payment,保存支付与退款记录。 type FinPayment struct { 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 业务字段 + 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;check:amount > 0" json:"amount"` // amount 业务字段 + PaidAt *time.Time `gorm:"column:paid_at;type:timestamptz" json:"paid_at"` // paid_at 业务字段 } func init() { database.AppendMigrate(&FinPayment{}) } diff --git a/backend/api/internal/models/fin_settlement.go b/backend/api/internal/models/fin_settlement.go index 8d2ee3f..a6d96fa 100644 --- a/backend/api/internal/models/fin_settlement.go +++ b/backend/api/internal/models/fin_settlement.go @@ -8,11 +8,11 @@ import ( // FinSettlement 对应 fin_settlement,保存结算单。 type FinSettlement struct { 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 业务字段 + 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;check:period_end > period_start" json:"period_end"` // period_end 业务字段 } func init() { database.AppendMigrate(&FinSettlement{}) } diff --git a/backend/api/internal/models/gasorder_basic.go b/backend/api/internal/models/gasorder_basic.go index 04119e7..3e1037c 100644 --- a/backend/api/internal/models/gasorder_basic.go +++ b/backend/api/internal/models/gasorder_basic.go @@ -4,7 +4,7 @@ import "git.apinb.com/bsm-sdk/core/database" // GasorderBasic 对应 gasorder_basic,保存气体配送订单当前快照。 type GasorderBasic struct { - Entity // 公共实体字段,Status 保存订单当前状态 + Entity // 公共实体字段 OrderNo string `gorm:"column:order_no;type:varchar(64);not null;uniqueIndex" json:"order_no"` // 订单编号 RequestNo string `gorm:"column:request_no;type:varchar(128);not null;uniqueIndex" json:"request_no"` // 创建幂等号 GasorderContractID uint64 `gorm:"column:gasorder_contract_id;not null;index" json:"gasorder_contract_id"` // 合同自增主键 diff --git a/backend/api/internal/models/gasorder_contract_product.go b/backend/api/internal/models/gasorder_contract_product.go index 56fda30..2d05917 100644 --- a/backend/api/internal/models/gasorder_contract_product.go +++ b/backend/api/internal/models/gasorder_contract_product.go @@ -17,6 +17,7 @@ type GasorderContractProduct struct { UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 约定充装单价,单位分 BoundAt time.Time `gorm:"column:bound_at;type:timestamptz;not null" json:"bound_at"` // 绑定时间 UnboundAt *time.Time `gorm:"column:unbound_at;type:timestamptz;index" json:"unbound_at"` // 解绑时间 + UnbindReason string `gorm:"column:unbind_reason;type:text;not null;default:''" json:"unbind_reason"` // 解绑原因 } func init() { database.AppendMigrate(&GasorderContractProduct{}) } diff --git a/backend/api/internal/models/gasorder_item.go b/backend/api/internal/models/gasorder_item.go index 3e9cc15..62003b6 100644 --- a/backend/api/internal/models/gasorder_item.go +++ b/backend/api/internal/models/gasorder_item.go @@ -5,13 +5,14 @@ import "git.apinb.com/bsm-sdk/core/database" // GasorderItem 对应 gasorder_item,保存订单气瓶与规格价格快照。 type GasorderItem struct { Entity // 公共实体字段 - GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键 - GasorderContractProductID uint64 `gorm:"column:gasorder_contract_product_id;not null;index" json:"gasorder_contract_product_id"` // 合同气瓶绑定自增主键 - ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 实体气瓶自增主键 - ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照 - ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照 - ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照 - UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 成交单价,单位分 + GasorderBasicID uint64 `gorm:"column:gasorder_basic_id;not null;index" json:"gasorder_basic_id"` // 订单自增主键 + GasorderContractProductID uint64 `gorm:"column:gasorder_contract_product_id;not null;index" json:"gasorder_contract_product_id"` // 合同气瓶绑定自增主键 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_active_gasorder_product,where:active = true" json:"product_info_id"` // 实体气瓶自增主键 + Active bool `gorm:"column:active;not null;default:true;index" json:"active"` // 是否仍占用该气瓶 + ProductCode string `gorm:"column:product_code;type:varchar(64);not null" json:"product_code"` // 产品标识快照 + ProductTypeName string `gorm:"column:product_type_name;type:varchar(128);not null" json:"product_type_name"` // 产品类型名称快照 + ProductParams string `gorm:"column:product_params;type:text;not null;default:'{}'" json:"product_params"` // 产品规格参数快照 + UnitPrice int64 `gorm:"column:unit_price;not null;check:unit_price >= 0" json:"unit_price"` // 成交单价,单位分 } func init() { database.AppendMigrate(&GasorderItem{}) } diff --git a/backend/api/internal/models/platform_role_menu.go b/backend/api/internal/models/platform_role_menu.go index db21748..affbd2c 100644 --- a/backend/api/internal/models/platform_role_menu.go +++ b/backend/api/internal/models/platform_role_menu.go @@ -8,10 +8,10 @@ import ( // PlatformRoleMenu 对应 platform_role_menu,记录角色拥有的静态菜单权限。 type PlatformRoleMenu struct { - ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键 - PlatformRoleID uint64 `gorm:"column:platform_role_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_role_id"` // 角色自增主键 - MenuCode string `gorm:"column:menu_code;type:varchar(64);not null;uniqueIndex:uk_platform_role_menu" json:"menu_code"` // 静态菜单编码 - CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间 + ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` // 数据库自增主键 + PlatformRoleID uint64 `gorm:"column:platform_role_id;not null;uniqueIndex:uk_platform_role_menu" json:"platform_role_id"` // 角色自增主键 + MenuIdentity string `gorm:"column:menu_identity;type:varchar(64);not null;uniqueIndex:uk_platform_role_menu" json:"menu_identity"` // 静态菜单唯一标识 + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null" json:"created_at"` // 创建时间 } func init() { database.AppendMigrate(&PlatformRoleMenu{}) } diff --git a/backend/api/internal/models/product_repair.go b/backend/api/internal/models/product_repair.go index fc95f02..7cad870 100644 --- a/backend/api/internal/models/product_repair.go +++ b/backend/api/internal/models/product_repair.go @@ -9,16 +9,16 @@ import ( // ProductRepair 对应 product_repair,保存产品检修过程与结果。 type ProductRepair struct { Entity // 公共实体字段 - ProductInfoID uint64 `gorm:"column:product_info_id;not null;index" json:"product_info_id"` // 产品档案自增主键 - RepairNo string `gorm:"column:repair_no;type:varchar(64);not null;uniqueIndex" json:"repair_no"` // 检修单号 - RepairType string `gorm:"column:repair_type;type:varchar(32);not null" json:"repair_type"` // 检修类型 - StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null" json:"started_at"` // 开始时间 - CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间 - Result string `gorm:"column:result;type:varchar(32);not null;default:'pending'" json:"result"` // 检修结果 - TargetStatus int `gorm:"column:target_status;not null;default:0" json:"target_status"` // 完成后的产品状态 - Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // 检修内容 - Operator string `gorm:"column:operator;type:varchar(64);not null;default:''" json:"operator"` // 检修人员 - Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注 + ProductInfoID uint64 `gorm:"column:product_info_id;not null;index;uniqueIndex:idx_pending_product_repair,where:result = 'pending'" json:"product_info_id"` // 产品档案自增主键 + RepairNo string `gorm:"column:repair_no;type:varchar(64);not null;uniqueIndex" json:"repair_no"` // 检修单号 + RepairType string `gorm:"column:repair_type;type:varchar(32);not null" json:"repair_type"` // 检修类型 + StartedAt time.Time `gorm:"column:started_at;type:timestamptz;not null" json:"started_at"` // 开始时间 + CompletedAt *time.Time `gorm:"column:completed_at;type:timestamptz" json:"completed_at"` // 完成时间 + Result string `gorm:"column:result;type:varchar(32);not null;default:'pending'" json:"result"` // 检修结果 + TargetStatus int `gorm:"column:target_status;not null;default:0" json:"target_status"` // 完成后的产品状态 + Content string `gorm:"column:content;type:text;not null;default:''" json:"content"` // 检修内容 + Operator string `gorm:"column:operator;type:varchar(64);not null;default:''" json:"operator"` // 检修人员 + Remark string `gorm:"column:remark;type:text;not null;default:''" json:"remark"` // 备注 } func init() { database.AppendMigrate(&ProductRepair{}) } diff --git a/backend/api/internal/models/user_address.go b/backend/api/internal/models/user_address.go index c657a6e..5090f9e 100644 --- a/backend/api/internal/models/user_address.go +++ b/backend/api/internal/models/user_address.go @@ -5,11 +5,11 @@ 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"` // 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 业务字段 + UserAccountID uint64 `gorm:"column:user_account_id;not null;index;uniqueIndex:idx_default_user_address,where:is_default = true" json:"user_account_id"` // user_account_id 业务字段 + Address string `gorm:"column:address;type:varchar(255);not null" json:"address"` // address 业务字段 + Longitude string `gorm:"column:longitude;type:varchar(32);not null;default:''" json:"longitude"` // longitude 业务字段 + Latitude string `gorm:"column:latitude;type:varchar(32);not null;default:''" json:"latitude"` // latitude 业务字段 + IsDefault bool `gorm:"column:is_default;not null;default:false" json:"is_default"` // is_default 业务字段 } func init() { database.AppendMigrate(&UserAddress{}) } diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 2f0c410..8c0cdcc 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -82,6 +82,9 @@ func registerGasorderRoute(group *gin.RouterGroup) { order.POST("/:identity/assign", gasorder.AssignGasorderBasic) order.POST("/:identity/filling", gasorder.GasorderStartFilling) order.POST("/:identity/ready", gasorder.GasorderReady) + order.POST("/:identity/delivering", gasorder.GasorderStartDelivering) + order.POST("/:identity/awaiting-confirmation", gasorder.GasorderAwaitingConfirmation) + order.POST("/:identity/complete", gasorder.GasorderComplete) order.POST("/:identity/exception", gasorder.GasorderException) order.POST("/:identity/recover", gasorder.GasorderRecover) order.POST("/:identity/cancel", gasorder.GasorderCancel) @@ -119,16 +122,9 @@ func registerProductRoute(group *gin.RouterGroup) { ) registerNoDeleteResource(group, "/product_repair", repairList, repairCreate, repairGet, repairUpdate, &models.ProductRepair{}) - ownerList, ownerCreate, ownerGet := product.ProductOwnerHandlers( - requiredRelation("product_info_identity", "product_info_id", &models.ProductInfo{}), - optionalRelation("warehouse_identity", "warehouse_id", &models.ProductWarehouse{}), - optionalRelation("gas_basic_identity", "gas_basic_id", &models.GasBasic{}), - optionalRelation("delivery_basic_identity", "delivery_basic_id", &models.DeliveryBasic{}), - optionalRelation("user_account_identity", "user_account_id", &models.UserAccount{}), - ) + ownerList, ownerGet := product.ProductOwnerHandlers() owner := group.Group("/product_owner") owner.GET("", ownerList) - owner.POST("", ownerCreate) owner.GET("/:identity", ownerGet) } @@ -164,9 +160,7 @@ func registerWalletRoute(group *gin.RouterGroup) { } func registerCommerceRoute(group *gin.RouterGroup) { - categoryRelations := []common.ResourceRelation{optionalRelation("parent_identity", "parent_id", &models.EcCategory{})} - _, categoryCreate, _, categoryUpdate := common.ResourceHandlers(&models.EcCategory{}, []string{"name", "sort_no"}, []string{"name", "sort_no"}, categoryRelations...) - registerWritableResource(group, "/ec_category", ec.ListEcCategory, categoryCreate, ec.GetEcCategory, categoryUpdate, &models.EcCategory{}) + registerWritableResource(group, "/ec_category", ec.ListEcCategory, ec.CreateEcCategory, ec.GetEcCategory, ec.UpdateEcCategory, &models.EcCategory{}) registerRestrictedWritableResource(group, "/ec_product", &models.EcProduct{}, []string{"product_code", "name", "price_amount", "stock_quantity"}, requiredRelation("ec_category_identity", "ec_category_id", &models.EcCategory{})) registerRestrictedWritableResource(group, "/ec_product_attribute", &models.EcProductAttribute{}, []string{"name", "value", "sort_no"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) registerRestrictedWritableResource(group, "/ec_product_image", &models.EcProductImage{}, []string{"image_uri", "sort_no", "is_cover"}, requiredRelation("ec_product_identity", "ec_product_id", &models.EcProduct{})) diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 83a6506..875e6b7 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -148,7 +148,8 @@ func TestPlatformProductCommerceAndDeliveryRoutesFollowTheirContracts(t *testing assertNoRouteMethods(t, routes, path+"/:identity", http.MethodDelete) } owner := "/heqi/platform/v1/product_owner" - assertRouteMethods(t, routes, owner, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, owner, http.MethodGet) + assertNoRouteMethods(t, routes, owner, http.MethodPost) assertRouteMethods(t, routes, owner+"/:identity", http.MethodGet) assertNoRouteMethods(t, routes, owner+"/:identity", http.MethodPut, http.MethodPatch, http.MethodDelete) diff --git a/backend/api/internal/seed/mock.go b/backend/api/internal/seed/mock.go index 4e6459e..bcf88d2 100644 --- a/backend/api/internal/seed/mock.go +++ b/backend/api/internal/seed/mock.go @@ -455,11 +455,11 @@ func MockData(database *gorm.DB) error { } roleMenu := models.PlatformRoleMenu{ - PlatformRoleID: role.ID, MenuCode: "dashboard", + PlatformRoleID: role.ID, MenuIdentity: "dashboard_overview", } if err := tx.Where( - "platform_role_id = ? AND menu_code = ?", - role.ID, roleMenu.MenuCode, + "platform_role_id = ? AND menu_identity = ?", + role.ID, roleMenu.MenuIdentity, ).FirstOrCreate(&roleMenu).Error; err != nil { return fmt.Errorf("seed platform_role_menu: %w", err) } @@ -471,7 +471,6 @@ func entity(sequence int, status int) models.Entity { return models.Entity{ Identity: fmt.Sprintf("%s%012d", mockIdentityPrefix, sequence), Status: status, - Version: 1, } } diff --git a/backend/api/internal/seed/mock_test.go b/backend/api/internal/seed/mock_test.go index 245d866..61ace66 100644 --- a/backend/api/internal/seed/mock_test.go +++ b/backend/api/internal/seed/mock_test.go @@ -21,7 +21,7 @@ func TestMockEntityIdentitiesAreStableAndUnique(t *testing.T) { t.Fatalf("duplicate identity %q", record.Identity) } seen[record.Identity] = struct{}{} - if record.Status != common.StatusEnable || record.Version != 1 { + if record.Status != common.StatusEnable { t.Fatalf("entity(%d) has unexpected defaults: %#v", sequence, record) } } diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 1e9813b..dec79cc 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -186,7 +186,7 @@ const fieldLabels: Record = { const numbers = new Set([ 'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no', - 'attempt_no', + 'attempt_no', 'target_status', ]); const money = new Set([ 'unit_price', 'amount', 'fee', 'balance', 'withdrawal_balance', @@ -283,8 +283,8 @@ export const resources: ResourceUiDefinition[] = [ define('product_info', '产品信息', 'editable', [f('code', { required: true }), f('name', { required: true }), relation('product_type_identity', '/product_type', true), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('produced_at', { required: true }), f('is_enabled')], 'list', [ { name: '修改产品状态', resource: '/product_info/:identity/status', method: 'PATCH', fields: [f('status', { required: true, type: 'select', options: [{ label: '待处理', value: 10 }, { label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '维修中', value: 31 }, { label: '已报废', value: 27 }] })] }, ]), - define('product_repair', '产品检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result'), f('target_status'), f('content'), f('operator'), f('remark')]), - define('product_owner', '产品归属记录', 'append_only', [relation('product_info_identity', '/product_info', true), relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('action', { required: true }), f('occurred_at', { required: true }), f('reason'), f('remark')]), + define('product_repair', '产品检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: [{ label: '待处理', value: 'pending' }, { label: '通过', value: 'passed' }, { label: '未通过', value: 'failed' }] }), f('target_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]), + define('product_owner', '产品归属记录', 'readonly', []), define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [ { name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason }, @@ -299,6 +299,9 @@ export const resources: ResourceUiDefinition[] = [ { name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason] }, { name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason }, { name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason }, + { name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason }, + { name: '等待签收', resource: '/gasorder_basic/:identity/awaiting-confirmation', fields: reason }, + { name: '完成订单', resource: '/gasorder_basic/:identity/complete', fields: [...reason, f('confirm_type', { required: true }), f('recipient_name', { required: true }), f('recipient_phone'), f('proof_uri'), f('remark')] }, { name: '标记异常', resource: '/gasorder_basic/:identity/exception', fields: reason }, { name: '恢复订单', resource: '/gasorder_basic/:identity/recover', fields: reason }, { name: '取消订单', resource: '/gasorder_basic/:identity/cancel', danger: true, fields: reason }, diff --git a/frontend/platform_admin/src/contracts/platform-resources.json b/frontend/platform_admin/src/contracts/platform-resources.json index 5b7c6e8..4f4467d 100644 --- a/frontend/platform_admin/src/contracts/platform-resources.json +++ b/frontend/platform_admin/src/contracts/platform-resources.json @@ -1 +1 @@ -{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"append_only"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"writable"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"writable"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/ec_cart"},{"method":"POST","path":"/ec_order"},{"method":"POST","path":"/ec_order_item"},{"method":"POST","path":"/ec_review"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/product_owner"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/fin_payment"},{"method":"POST","path":"/fin_settlement"},{"method":"POST","path":"/fin_reconciliation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/ec_cart/:identity"},{"method":"PUT","path":"/ec_order/:identity"},{"method":"PUT","path":"/ec_order_item/:identity"},{"method":"PUT","path":"/ec_review/:identity"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/fin_payment/:identity"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PUT","path":"/fin_reconciliation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/ec_cart/:identity/status"},{"method":"PATCH","path":"/ec_order/:identity/status"},{"method":"PATCH","path":"/ec_order_item/:identity/status"},{"method":"PATCH","path":"/ec_review/:identity/status"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/fin_payment/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"PATCH","path":"/fin_reconciliation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/ec_cart/:identity"},{"method":"DELETE","path":"/ec_order/:identity"},{"method":"DELETE","path":"/ec_order_item/:identity"},{"method":"DELETE","path":"/ec_review/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/fin_payment/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"},{"method":"DELETE","path":"/fin_reconciliation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"}]} +{"resources":[{"domain":"gas","name":"gas_basic","path":"/gas_basic","pageKind":"list","mode":"writable"},{"domain":"gas","name":"gas_account","path":"/gas_account","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_basic","path":"/delivery_basic","pageKind":"list","mode":"writable"},{"domain":"delivery","name":"delivery_account","path":"/delivery_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_account","path":"/staff_account","pageKind":"list","mode":"writable"},{"domain":"staff","name":"staff_credential","path":"/staff_credential","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_account","path":"/user_account","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_address","path":"/user_address","pageKind":"list","mode":"writable"},{"domain":"user","name":"user_service_relation","path":"/user_service_relation","pageKind":"list","mode":"writable"},{"domain":"product","name":"product_type","path":"/product_type","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_warehouse","path":"/product_warehouse","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_info","path":"/product_info","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_repair","path":"/product_repair","pageKind":"list","mode":"editable"},{"domain":"product","name":"product_owner","path":"/product_owner","pageKind":"list","mode":"readonly"},{"domain":"ec","name":"ec_category","path":"/ec_category","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product","path":"/ec_product","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_attribute","path":"/ec_product_attribute","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_product_image","path":"/ec_product_image","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_cart","path":"/ec_cart","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_order","path":"/ec_order","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_order_item","path":"/ec_order_item","pageKind":"list","mode":"writable"},{"domain":"ec","name":"ec_review","path":"/ec_review","pageKind":"list","mode":"writable"},{"domain":"gasorder","name":"gasorder_contract","path":"/gasorder_contract","pageKind":"list","mode":"managed"},{"domain":"gasorder","name":"gasorder_contract_product","path":"/gasorder_contract_product","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_contract_revision","path":"/gasorder_contract_revision","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_basic","path":"/gasorder_basic","pageKind":"list","mode":"append_only"},{"domain":"gasorder","name":"gasorder_item","path":"/gasorder_item","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_assign","path":"/gasorder_assign","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_status","path":"/gasorder_status","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track","path":"/gasorder_track","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_track_point","path":"/gasorder_track_point","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_confirm","path":"/gasorder_confirm","pageKind":"list","mode":"readonly"},{"domain":"gasorder","name":"gasorder_payment","path":"/gasorder_payment","pageKind":"list","mode":"readonly"},{"domain":"finance","name":"fin_payment","path":"/fin_payment","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_settlement","path":"/fin_settlement","pageKind":"list","mode":"writable"},{"domain":"finance","name":"fin_reconciliation","path":"/fin_reconciliation","pageKind":"list","mode":"writable"},{"domain":"content","name":"cms_content","path":"/cms_content","pageKind":"list","mode":"writable"},{"domain":"customer_service","name":"cs_ticket","path":"/cs_ticket","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_account","path":"/platform_account","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_role","path":"/platform_role","pageKind":"list","mode":"writable"},{"domain":"platform","name":"platform_menu","path":"/platform_menu","pageKind":"tree","mode":"readonly"},{"domain":"wallet","name":"wallet_basic","path":"/wallet_basic","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_bank","path":"/wallet_bank","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_payment","path":"/wallet_payment","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_record","path":"/wallet_record","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_refund","path":"/wallet_refund","pageKind":"list","mode":"readonly"},{"domain":"wallet","name":"wallet_apply_cash","path":"/wallet_apply_cash","pageKind":"list","mode":"readonly"}],"routes":[{"method":"GET","path":"/gasorder_contract"},{"method":"GET","path":"/gasorder_contract_product"},{"method":"GET","path":"/gasorder_contract_product/:identity"},{"method":"GET","path":"/gasorder_contract_revision"},{"method":"GET","path":"/gasorder_contract_revision/:identity"},{"method":"GET","path":"/gasorder_contract/:identity"},{"method":"GET","path":"/gasorder_confirm"},{"method":"GET","path":"/gasorder_confirm/:identity"},{"method":"GET","path":"/gasorder_track"},{"method":"GET","path":"/gasorder_track_point"},{"method":"GET","path":"/gasorder_track_point/:identity"},{"method":"GET","path":"/gasorder_track/:identity"},{"method":"GET","path":"/gasorder_basic"},{"method":"GET","path":"/gasorder_basic/:identity"},{"method":"GET","path":"/gasorder_item"},{"method":"GET","path":"/gasorder_item/:identity"},{"method":"GET","path":"/gasorder_assign"},{"method":"GET","path":"/gasorder_assign/:identity"},{"method":"GET","path":"/gasorder_status"},{"method":"GET","path":"/gasorder_status/:identity"},{"method":"GET","path":"/gasorder_payment"},{"method":"GET","path":"/gasorder_payment/:identity"},{"method":"GET","path":"/gas_basic"},{"method":"GET","path":"/gas_basic/:identity"},{"method":"GET","path":"/gas_account"},{"method":"GET","path":"/gas_account/:identity"},{"method":"GET","path":"/product_type"},{"method":"GET","path":"/product_type/:identity"},{"method":"GET","path":"/product_warehouse"},{"method":"GET","path":"/product_warehouse/:identity"},{"method":"GET","path":"/product_info"},{"method":"GET","path":"/product_info/:identity"},{"method":"GET","path":"/product_repair"},{"method":"GET","path":"/product_repair/:identity"},{"method":"GET","path":"/product_owner"},{"method":"GET","path":"/product_owner/:identity"},{"method":"GET","path":"/platform_role"},{"method":"GET","path":"/platform_role/:identity"},{"method":"GET","path":"/platform_role/:identity/menu"},{"method":"GET","path":"/platform_account"},{"method":"GET","path":"/platform_account/:identity"},{"method":"GET","path":"/platform_menu"},{"method":"GET","path":"/platform_menu/:identity"},{"method":"GET","path":"/ping/hello"},{"method":"GET","path":"/ec_product"},{"method":"GET","path":"/ec_product_attribute"},{"method":"GET","path":"/ec_product_attribute/:identity"},{"method":"GET","path":"/ec_product_image"},{"method":"GET","path":"/ec_product_image/:identity"},{"method":"GET","path":"/ec_product/:identity"},{"method":"GET","path":"/ec_category"},{"method":"GET","path":"/ec_category/:identity"},{"method":"GET","path":"/ec_cart"},{"method":"GET","path":"/ec_cart/:identity"},{"method":"GET","path":"/ec_order"},{"method":"GET","path":"/ec_order_item"},{"method":"GET","path":"/ec_order_item/:identity"},{"method":"GET","path":"/ec_order/:identity"},{"method":"GET","path":"/ec_review"},{"method":"GET","path":"/ec_review/:identity"},{"method":"GET","path":"/wallet_basic"},{"method":"GET","path":"/wallet_basic/owner/:owner_type/:owner_identity"},{"method":"GET","path":"/wallet_basic/:identity"},{"method":"GET","path":"/wallet_bank"},{"method":"GET","path":"/wallet_bank/:identity"},{"method":"GET","path":"/wallet_record"},{"method":"GET","path":"/wallet_record/:identity"},{"method":"GET","path":"/wallet_refund"},{"method":"GET","path":"/wallet_refund/:identity"},{"method":"GET","path":"/wallet_payment"},{"method":"GET","path":"/wallet_payment/:identity"},{"method":"GET","path":"/wallet_apply_cash"},{"method":"GET","path":"/wallet_apply_cash/:identity"},{"method":"GET","path":"/user_account"},{"method":"GET","path":"/user_account/:identity"},{"method":"GET","path":"/user_address"},{"method":"GET","path":"/user_address/:identity"},{"method":"GET","path":"/user_service_relation"},{"method":"GET","path":"/user_service_relation/:identity"},{"method":"GET","path":"/fin_payment"},{"method":"GET","path":"/fin_payment/:identity"},{"method":"GET","path":"/fin_settlement"},{"method":"GET","path":"/fin_settlement/:identity"},{"method":"GET","path":"/fin_reconciliation"},{"method":"GET","path":"/fin_reconciliation/:identity"},{"method":"GET","path":"/delivery_basic"},{"method":"GET","path":"/delivery_basic/:identity"},{"method":"GET","path":"/delivery_account"},{"method":"GET","path":"/delivery_account/:identity"},{"method":"GET","path":"/dashboard/overview"},{"method":"GET","path":"/staff_account"},{"method":"GET","path":"/staff_account/:identity"},{"method":"GET","path":"/staff_credential"},{"method":"GET","path":"/staff_credential/:identity"},{"method":"GET","path":"/cms_content"},{"method":"GET","path":"/cms_content/:identity"},{"method":"GET","path":"/cs_ticket"},{"method":"GET","path":"/cs_ticket/:identity"},{"method":"GET","path":"/auth/profile"},{"method":"POST","path":"/gasorder_basic"},{"method":"POST","path":"/gasorder_basic/:identity/assign"},{"method":"POST","path":"/gasorder_basic/:identity/awaiting-confirmation"},{"method":"POST","path":"/gasorder_basic/:identity/ready"},{"method":"POST","path":"/gasorder_basic/:identity/recover"},{"method":"POST","path":"/gasorder_basic/:identity/complete"},{"method":"POST","path":"/gasorder_basic/:identity/cancel"},{"method":"POST","path":"/gasorder_basic/:identity/filling"},{"method":"POST","path":"/gasorder_basic/:identity/delivering"},{"method":"POST","path":"/gasorder_basic/:identity/exception"},{"method":"POST","path":"/gasorder_contract"},{"method":"POST","path":"/gasorder_contract/:identity/activate"},{"method":"POST","path":"/gasorder_contract/:identity/renew"},{"method":"POST","path":"/gasorder_contract/:identity/terminate"},{"method":"POST","path":"/gasorder_contract_product"},{"method":"POST","path":"/gasorder_contract_product/:identity/unbind"},{"method":"POST","path":"/gas_basic"},{"method":"POST","path":"/gas_account"},{"method":"POST","path":"/ec_product"},{"method":"POST","path":"/ec_product_attribute"},{"method":"POST","path":"/ec_product_image"},{"method":"POST","path":"/ec_category"},{"method":"POST","path":"/ec_cart"},{"method":"POST","path":"/ec_order"},{"method":"POST","path":"/ec_order_item"},{"method":"POST","path":"/ec_review"},{"method":"POST","path":"/product_type"},{"method":"POST","path":"/product_warehouse"},{"method":"POST","path":"/product_info"},{"method":"POST","path":"/product_repair"},{"method":"POST","path":"/platform_account"},{"method":"POST","path":"/platform_role"},{"method":"POST","path":"/user_account"},{"method":"POST","path":"/user_address"},{"method":"POST","path":"/user_service_relation"},{"method":"POST","path":"/wallet_apply_cash/:identity/approve"},{"method":"POST","path":"/wallet_apply_cash/:identity/reject"},{"method":"POST","path":"/wallet_basic/:identity/recharge"},{"method":"POST","path":"/fin_payment"},{"method":"POST","path":"/fin_settlement"},{"method":"POST","path":"/fin_reconciliation"},{"method":"POST","path":"/delivery_basic"},{"method":"POST","path":"/delivery_account"},{"method":"POST","path":"/staff_account"},{"method":"POST","path":"/staff_credential"},{"method":"POST","path":"/cms_content"},{"method":"POST","path":"/cs_ticket"},{"method":"POST","path":"/auth/login"},{"method":"PUT","path":"/ec_product_attribute/:identity"},{"method":"PUT","path":"/ec_product_image/:identity"},{"method":"PUT","path":"/ec_product/:identity"},{"method":"PUT","path":"/ec_category/:identity"},{"method":"PUT","path":"/ec_cart/:identity"},{"method":"PUT","path":"/ec_order/:identity"},{"method":"PUT","path":"/ec_order_item/:identity"},{"method":"PUT","path":"/ec_review/:identity"},{"method":"PUT","path":"/product_type/:identity"},{"method":"PUT","path":"/product_warehouse/:identity"},{"method":"PUT","path":"/product_info/:identity"},{"method":"PUT","path":"/product_repair/:identity"},{"method":"PUT","path":"/platform_role/:identity"},{"method":"PUT","path":"/platform_role/:identity/menu"},{"method":"PUT","path":"/platform_account/:identity"},{"method":"PUT","path":"/gas_basic/:identity"},{"method":"PUT","path":"/gas_account/:identity"},{"method":"PUT","path":"/gasorder_contract/:identity"},{"method":"PUT","path":"/user_account/:identity"},{"method":"PUT","path":"/user_address/:identity"},{"method":"PUT","path":"/user_service_relation/:identity"},{"method":"PUT","path":"/fin_payment/:identity"},{"method":"PUT","path":"/fin_settlement/:identity"},{"method":"PUT","path":"/fin_reconciliation/:identity"},{"method":"PUT","path":"/delivery_basic/:identity"},{"method":"PUT","path":"/delivery_account/:identity"},{"method":"PUT","path":"/staff_account/:identity"},{"method":"PUT","path":"/staff_credential/:identity"},{"method":"PUT","path":"/cms_content/:identity"},{"method":"PUT","path":"/cs_ticket/:identity"},{"method":"PUT","path":"/auth/password"},{"method":"PATCH","path":"/ec_product_attribute/:identity/status"},{"method":"PATCH","path":"/ec_product_image/:identity/status"},{"method":"PATCH","path":"/ec_product/:identity/status"},{"method":"PATCH","path":"/ec_category/:identity/status"},{"method":"PATCH","path":"/ec_cart/:identity/status"},{"method":"PATCH","path":"/ec_order/:identity/status"},{"method":"PATCH","path":"/ec_order_item/:identity/status"},{"method":"PATCH","path":"/ec_review/:identity/status"},{"method":"PATCH","path":"/product_type/:identity/status"},{"method":"PATCH","path":"/product_warehouse/:identity/status"},{"method":"PATCH","path":"/product_info/:identity/status"},{"method":"PATCH","path":"/product_repair/:identity/status"},{"method":"PATCH","path":"/platform_account/:identity/status"},{"method":"PATCH","path":"/platform_role/:identity/status"},{"method":"PATCH","path":"/user_account/:identity/status"},{"method":"PATCH","path":"/user_address/:identity/status"},{"method":"PATCH","path":"/user_service_relation/:identity/status"},{"method":"PATCH","path":"/fin_payment/:identity/status"},{"method":"PATCH","path":"/fin_settlement/:identity/status"},{"method":"PATCH","path":"/fin_reconciliation/:identity/status"},{"method":"PATCH","path":"/gas_basic/:identity/status"},{"method":"PATCH","path":"/gas_account/:identity/status"},{"method":"PATCH","path":"/delivery_basic/:identity/status"},{"method":"PATCH","path":"/delivery_account/:identity/status"},{"method":"PATCH","path":"/staff_account/:identity/status"},{"method":"PATCH","path":"/staff_credential/:identity/status"},{"method":"PATCH","path":"/cms_content/:identity/status"},{"method":"PATCH","path":"/cs_ticket/:identity/status"},{"method":"PATCH","path":"/wallet_basic/:identity/status"},{"method":"DELETE","path":"/ec_product_attribute/:identity"},{"method":"DELETE","path":"/ec_product_image/:identity"},{"method":"DELETE","path":"/ec_product/:identity"},{"method":"DELETE","path":"/ec_category/:identity"},{"method":"DELETE","path":"/ec_cart/:identity"},{"method":"DELETE","path":"/ec_order/:identity"},{"method":"DELETE","path":"/ec_order_item/:identity"},{"method":"DELETE","path":"/ec_review/:identity"},{"method":"DELETE","path":"/user_account/:identity"},{"method":"DELETE","path":"/user_address/:identity"},{"method":"DELETE","path":"/user_service_relation/:identity"},{"method":"DELETE","path":"/fin_payment/:identity"},{"method":"DELETE","path":"/fin_settlement/:identity"},{"method":"DELETE","path":"/fin_reconciliation/:identity"},{"method":"DELETE","path":"/gas_basic/:identity"},{"method":"DELETE","path":"/gas_account/:identity"},{"method":"DELETE","path":"/delivery_basic/:identity"},{"method":"DELETE","path":"/delivery_account/:identity"},{"method":"DELETE","path":"/staff_account/:identity"},{"method":"DELETE","path":"/staff_credential/:identity"},{"method":"DELETE","path":"/cms_content/:identity"},{"method":"DELETE","path":"/cs_ticket/:identity"},{"method":"DELETE","path":"/platform_account/:identity"},{"method":"DELETE","path":"/platform_role/:identity"}]} diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 1e0cd7e..b7124c2 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -441,7 +441,7 @@ function openStatus(row: Row) { options: [ { label: '启用', value: 1 }, { label: '停用', value: 2 }, - { label: '归档', value: 'archived' }, + { label: '归档', value: 3 }, ], }, ],