diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 9a82299..236878f 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -10,6 +10,7 @@ import ( goautodevice "go-admin/app/goauto/device" goautoproduct "go-admin/app/goauto/product" goautopurchase "go-admin/app/goauto/purchase" + goautopurchaserule "go-admin/app/goauto/purchaserule" goautoreplacement "go-admin/app/goauto/replacement" goautorule "go-admin/app/goauto/rule" goautoshopeeproduct "go-admin/app/goauto/shopeeproduct" @@ -54,6 +55,7 @@ func InitRouter() { goautoaimatching.InitRouter(r, authMiddleware) goautotask.InitRouter(r, authMiddleware) goautopurchase.InitRouter(r, authMiddleware) + goautopurchaserule.InitRouter(r, authMiddleware) goautoproduct.InitRouter(r, authMiddleware) goautoreplacement.InitRouter(r, authMiddleware) goautorule.InitRouter(r, authMiddleware) diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go index f480cfe..040281f 100644 --- a/server/app/goauto/access/modules.go +++ b/server/app/goauto/access/modules.go @@ -11,6 +11,7 @@ const ( ModuleSYBSyncRuns = "syb_sync_runs" ModuleSYBInnerCodes = "syb_inner_codes" ModuleCollectionRules = "collection_rules" + ModulePurchaseRules = "purchase_rules" ModuleCollectionTasks = "collection_tasks" ModulePurchaseTasks = "purchase_tasks" ModuleAIMatching = "ai_matching" @@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{ ModuleKeys: []string{ ModuleSYBShops, ModuleCollectionRules, + ModulePurchaseRules, ModuleDevices, ModuleAIMatching, }, @@ -92,9 +94,10 @@ var goAutoModuleMetadata = []ModuleDefinition{ {Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true}, {Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true}, {Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true}, - {Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 58, PurchaserDefault: true}, - {Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 59, PurchaserDefault: true}, - {Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 60, PurchaserHardHidden: true}, + {Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true}, + {Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true}, + {Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true}, + {Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true}, } // GoAutoModules returns independent copies so callers cannot mutate the @@ -141,6 +144,8 @@ func moduleKeyForAPI(path string) string { return ModuleSYBInnerCodes case strings.HasPrefix(path, "/api/admin/v1/collection-rules"): return ModuleCollectionRules + case strings.HasPrefix(path, "/api/admin/v1/purchase-rules"): + return ModulePurchaseRules case strings.HasPrefix(path, "/api/admin/v1/collection-tasks"): return ModuleCollectionTasks case strings.HasPrefix(path, "/api/admin/v1/purchase-tasks"): diff --git a/server/app/goauto/access/modules_test.go b/server/app/goauto/access/modules_test.go index 498d4d7..c66bfae 100644 --- a/server/app/goauto/access/modules_test.go +++ b/server/app/goauto/access/modules_test.go @@ -10,8 +10,8 @@ import ( func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) { modules := GoAutoModules() - if len(modules) != 11 { - t.Fatalf("got %d modules, want 11", len(modules)) + if len(modules) != 12 { + t.Fatalf("got %d modules, want 12", len(modules)) } owners := make(map[string]int) @@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) { } previous = module.Sort } - if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 60 { - t.Fatalf("GoAuto menu sort range = %d..%d, want 50..60", modules[0].Sort, modules[len(modules)-1].Sort) + if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 { + t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort) } } @@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) { wantOrder := [][]string{ {ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks}, - {ModuleSYBShops, ModuleCollectionRules, ModuleDevices, ModuleAIMatching}, + {ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching}, } seen := make(map[string]int) for groupIndex, group := range groups { diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index b1aa36b..c37c62b 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -74,6 +74,13 @@ var AdminAPIs = []APIPermission{ {"修改采集规则", "/api/admin/v1/collection-rules/:ruleId", "PATCH", false}, {"删除采集规则", "/api/admin/v1/collection-rules/:ruleId", "DELETE", false}, + {"查看采购规则", "/api/admin/v1/purchase-rules", "GET", false}, + {"查看当前采购规则", "/api/admin/v1/purchase-rules/current", "GET", false}, + {"新增采购规则", "/api/admin/v1/purchase-rules", "POST", false}, + {"修改采购规则", "/api/admin/v1/purchase-rules/:ruleId", "PATCH", false}, + {"删除采购规则", "/api/admin/v1/purchase-rules/:ruleId", "DELETE", false}, + {"设置当前采购规则", "/api/admin/v1/purchase-rules/current", "PUT", false}, + {"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true}, {"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true}, {"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true}, diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index d3f3af8..2276cf5 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -50,6 +50,8 @@ func MigratedModels() []any { &models.PurchaseTaskAttempt{}, &models.PurchaseSpecMatchWorkItem{}, &models.CollectionRule{}, + &models.PurchaseRule{}, + &models.PurchaseRuleSetting{}, &models.AgentManualCollectionSetting{}, &models.CollectionTask{}, &models.CollectionTaskAttempt{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 7227b84..62e2f17 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -112,6 +112,35 @@ type CollectionRule struct { func (CollectionRule) TableName() string { return "collection_rule" } +// PurchaseRule stores an administrator-reviewed purchase execution contract. +// Tasks copy ContentJSON into their immutable rule snapshot at creation time. +type PurchaseRule struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" gorm:"size:120;not null"` + ContentJSON string `json:"content" gorm:"type:text;not null"` + LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_create_request_id"` + LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_update_request_id"` + LastDeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_delete_request_id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (PurchaseRule) TableName() string { return "purchase_rule" } + +// PurchaseRuleSetting selects the single rule used by newly created live +// purchase tasks. There is deliberately no built-in fallback at runtime. +type PurchaseRuleSetting struct { + ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"` + RuleID uint64 `json:"ruleId" gorm:"not null;index"` + Rule PurchaseRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_setting_request_id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (PurchaseRuleSetting) TableName() string { return "purchase_rule_setting" } + // CollectionTask stores both task lifecycle and result summary. ActiveSlot and // DeviceRunSlot are nullable guard columns: NULL permits multiple terminal rows, // while value 1 makes the composite unique indexes enforce active-task limits diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index ffa2037..4b1204e 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -12,6 +12,7 @@ import ( "go-admin/app/goauto/models" "go-admin/app/goauto/product" "go-admin/app/goauto/purchasecontract" + "go-admin/app/goauto/purchaserule" "github.com/google/uuid" "gorm.io/gorm" @@ -84,11 +85,15 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba if err != nil { return BatchPreviewResponse{}, err } + _, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + return BatchPreviewResponse{}, err + } dataset, err := s.loadBatchPreviewDataset(ctx, ids) if err != nil { return BatchPreviewResponse{}, internal(err) } - deviceErr := s.validateBatchDevice(ctx, req.DeviceID) + deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule) items := make([]BatchPreviewItem, 0, len(ids)) eligible := 0 for _, id := range ids { @@ -202,7 +207,11 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc if err != nil { return BatchCreateResponse{}, err } - deviceErr := s.validateBatchDevice(ctx, req.DeviceID) + _, rawRule, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + return BatchCreateResponse{}, err + } + deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule) result := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(ids))} for _, id := range ids { itemRequestID := batchItemRequestID(req.RequestID, id) @@ -234,7 +243,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc SYBProductID: &sybID, DeviceID: req.DeviceID, ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent, MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent, - Currency: preview.Currency, RuleSnapshot: purchasecontract.DefaultLiveRule(), + Currency: preview.Currency, RuleSnapshot: rawRule, }) if createErr != nil { preview.Eligible = false @@ -266,7 +275,7 @@ func validateBatchIDs(raw []uint64) ([]uint64, error) { return ids, nil } -func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) error { +func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rule purchasecontract.RuleSnapshot) error { if deviceID == nil { return nil } @@ -277,10 +286,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) err if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil { return fail(CodeInvalidRequest, "所选设备当前不可用,请选择在线设备或留空") } - rule, err := purchasecontract.Validate(purchasecontract.DefaultLiveRule(), models.PurchaseExecutionModeLive) - if err != nil { - return internal(err) - } return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule)) } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 9dced0c..60afbed 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -155,8 +155,8 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { if matcher.calls != 0 { t.Fatalf("read-only preview called AI matcher %d times", matcher.calls) } - if queries != 4 { - t.Fatalf("batch preview used %d queries, want 4 bulk queries", queries) + if queries != 5 { + t.Fatalf("batch preview used %d queries, want 5 bounded queries including current purchase rule", queries) } if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible { t.Fatalf("unresolved row should remain selectable for full create-time matching: %+v", response) diff --git a/server/app/goauto/purchase/reset.go b/server/app/goauto/purchase/reset.go index 9a2916d..6c292dc 100644 --- a/server/app/goauto/purchase/reset.go +++ b/server/app/goauto/purchase/reset.go @@ -10,6 +10,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" "go-admin/app/goauto/purchasecontract" + "go-admin/app/goauto/purchaserule" "github.com/google/uuid" "gorm.io/gorm" @@ -91,10 +92,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq return err } - rawRule := purchasecontract.DefaultLiveRule() - rule, err := purchasecontract.Validate(rawRule, models.PurchaseExecutionModeLive) + _, rawRule, rule, err := purchaserule.CurrentRule(ctx, tx, models.PurchaseExecutionModeLive) if err != nil { - return fail(CodeInvalidRequest, "当前采购规则不可用,请联系管理员") + return err } required := purchasecontract.RequiredCapabilities(rule) if err := ensureCapabilities(deviceRecord, required); err != nil { diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 1cd0d1b..4ba3cfb 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -8,7 +8,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" - "go-admin/app/goauto/purchasecontract" + "go-admin/app/goauto/purchaserule" "go-admin/app/goauto/replacement" "github.com/google/uuid" @@ -76,6 +76,10 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR if err != nil { return BatchRetryResponse{}, err } + _, rawRule, _, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + return BatchRetryResponse{}, err + } response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))} prepared := make([]preparedRetry, 0, len(ids)) @@ -124,7 +128,7 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR MinUnitPriceCent: candidate.preview.MinUnitPriceCent, MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent, Currency: candidate.preview.Currency, - RuleSnapshot: purchasecontract.DefaultLiveRule(), + RuleSnapshot: rawRule, }) item := response.Items[candidate.resultIndex] if createErr != nil { @@ -316,7 +320,12 @@ func (s *Service) retryDeviceEligibility(ctx context.Context, task models.Purcha return retryDecision{ReasonCode: code, Reason: message} } if task.DeviceID != nil { - if err := s.validateBatchDevice(ctx, task.DeviceID); err != nil { + _, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if ruleErr != nil { + code, message, _ := serviceErrorFields(ruleErr) + return deny(code, message) + } + if err := s.validateBatchDevice(ctx, task.DeviceID, currentRule); err != nil { code, message, _ := serviceErrorFields(err) if code == CodeInvalidRequest { message = "原设备当前不可用,请先恢复设备" diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 8b8f462..c619646 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -39,6 +39,13 @@ func testDB(t *testing.T) *gorm.DB { if err = migrations.Migrate(db); err != nil { t.Fatal(err) } + defaultRule := models.PurchaseRule{Name: "测试当前采购规则", ContentJSON: string(purchasecontract.DefaultLiveRule())} + if err = db.Create(&defaultRule).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&models.PurchaseRuleSetting{ID: 1, RuleID: defaultRule.ID}).Error; err != nil { + t.Fatal(err) + } return db } func testService(db *gorm.DB) *Service { diff --git a/server/app/goauto/purchaserule/handler.go b/server/app/goauto/purchaserule/handler.go new file mode 100644 index 0000000..df44539 --- /dev/null +++ b/server/app/goauto/purchaserule/handler.go @@ -0,0 +1,182 @@ +package purchaserule + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "gorm.io/gorm" +) + +type Handler struct{ DB *gorm.DB } + +func (h Handler) service(c *gin.Context) (*Service, bool) { + db := h.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil { + writeError(c, internal(err)) + return nil, false + } + return NewService(db), true +} +func (h Handler) List(c *gin.Context) { + page, err := positive(c.Query("page"), 1) + if err != nil { + writeError(c, invalid("page 必须是正整数")) + return + } + size, err := positive(c.Query("pageSize"), 20) + if err != nil { + writeError(c, invalid("pageSize 必须是正整数")) + return + } + s, ok := h.service(c) + if !ok { + return + } + v, err := s.List(c.Request.Context(), page, size, c.Query("name")) + if err != nil { + writeError(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": v}) +} +func (h Handler) Current(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + v, err := s.Current(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(200, gin.H{"code": 200, "data": v}) +} +func (h Handler) Create(c *gin.Context) { + req, err := decode[SaveRequest](c) + if err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + s, ok := h.service(c) + if !ok { + return + } + v, err := s.Create(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": v}) +} +func (h Handler) Update(c *gin.Context) { + id, err := ruleID(c) + if err != nil { + writeError(c, invalid("ruleId 无效")) + return + } + req, err := decode[SaveRequest](c) + if err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + s, ok := h.service(c) + if !ok { + return + } + v, err := s.Update(c.Request.Context(), id, req) + if err != nil { + writeError(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": v}) +} +func (h Handler) Delete(c *gin.Context) { + id, err := ruleID(c) + if err != nil { + writeError(c, invalid("ruleId 无效")) + return + } + req, err := decode[DeleteRequest](c) + if err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + s, ok := h.service(c) + if !ok { + return + } + replayed, err := s.Delete(c.Request.Context(), id, req) + if err != nil { + writeError(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"ruleId": id, "replayed": replayed}}) +} +func (h Handler) SetCurrent(c *gin.Context) { + req, err := decode[SetCurrentRequest](c) + if err != nil { + writeError(c, invalid("请求 JSON 无效")) + return + } + s, ok := h.service(c) + if !ok { + return + } + rule, replayed, err := s.SetCurrent(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(200, gin.H{"code": 200, "data": gin.H{"rule": rule, "replayed": replayed}}) +} +func decode[T any](c *gin.Context) (T, error) { + var v T + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 300<<10) + d := json.NewDecoder(c.Request.Body) + d.DisallowUnknownFields() + if err := d.Decode(&v); err != nil { + return v, err + } + if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return v, errors.New("one object required") + } + return v, nil +} +func positive(v string, f int) (int, error) { + if v == "" { + return f, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return 0, errors.New("invalid") + } + return n, nil +} +func ruleID(c *gin.Context) (uint64, error) { return strconv.ParseUint(c.Param("ruleId"), 10, 64) } +func writeError(c *gin.Context, err error) { + var target *ServiceError + if !errors.As(err, &target) { + target = internal(err).(*ServiceError) + } + status := 500 + switch target.Code { + case CodeInvalidRequest, CodeRuleInvalid: + status = 422 + case CodeRuleNotFound, CodeCurrentRuleMissing: + status = 404 + case CodeCurrentRuleConflict: + status = 409 + } + c.JSON(status, gin.H{"code": target.Code, "message": target.Message, "retryable": target.Retryable}) +} diff --git a/server/app/goauto/purchaserule/router.go b/server/app/goauto/purchaserule/router.go new file mode 100644 index 0000000..915f9e6 --- /dev/null +++ b/server/app/goauto/purchaserule/router.go @@ -0,0 +1,18 @@ +package purchaserule + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/common/middleware" +) + +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + h := Handler{} + g := engine.Group("/api/admin/v1/purchase-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin")) + g.GET("", h.List) + g.GET("/current", h.Current) + g.POST("", h.Create) + g.PATCH("/:ruleId", h.Update) + g.DELETE("/:ruleId", h.Delete) + g.PUT("/current", h.SetCurrent) +} diff --git a/server/app/goauto/purchaserule/service.go b/server/app/goauto/purchaserule/service.go new file mode 100644 index 0000000..496306b --- /dev/null +++ b/server/app/goauto/purchaserule/service.go @@ -0,0 +1,312 @@ +package purchaserule + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + CodeInvalidRequest = "INVALID_REQUEST" + CodeRuleInvalid = "PURCHASE_RULE_INVALID" + CodeRuleNotFound = "PURCHASE_RULE_NOT_FOUND" + CodeCurrentRuleMissing = "CURRENT_PURCHASE_RULE_MISSING" + CodeCurrentRuleConflict = "CURRENT_PURCHASE_RULE_CONFLICT" + CodeInternal = "INTERNAL_ERROR" +) + +type ServiceError struct { + Code, Message string + Retryable bool + Cause error +} + +func (e *ServiceError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} +func (e *ServiceError) Unwrap() error { return e.Cause } + +type SaveRequest struct { + RequestID string `json:"requestId"` + Name string `json:"name"` + Content json.RawMessage `json:"content"` +} +type DeleteRequest struct { + RequestID string `json:"requestId"` +} +type SetCurrentRequest struct { + RequestID string `json:"requestId"` + RuleID uint64 `json:"ruleId"` +} +type RuleItem struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Content json.RawMessage `json:"content"` + IsCurrent bool `json:"isCurrent"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} +type ListResponse struct { + Items []RuleItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} +type SaveResponse struct { + Rule RuleItem `json:"rule"` + Replayed bool `json:"replayed,omitempty"` +} + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) *Service { return &Service{DB: db} } + +func CurrentRule(ctx context.Context, db *gorm.DB, mode string) (models.PurchaseRule, json.RawMessage, purchasecontract.RuleSnapshot, error) { + var setting models.PurchaseRuleSetting + if err := db.WithContext(ctx).Joins("Rule").First(&setting, 1).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, currentMissing("未配置当前采购规则,请联系管理员") + } + return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, internal(err) + } + raw := json.RawMessage(setting.Rule.ContentJSON) + rule, err := purchasecontract.Validate(raw, mode) + if err != nil { + return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, currentMissing("当前采购规则无效,请联系管理员") + } + return setting.Rule, raw, rule, nil +} + +func (s *Service) List(ctx context.Context, page, size int, name string) (ListResponse, error) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + q := s.DB.WithContext(ctx).Model(&models.PurchaseRule{}) + if name = strings.TrimSpace(name); name != "" { + q = q.Where("name LIKE ?", "%"+name+"%") + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return ListResponse{}, internal(err) + } + var rows []models.PurchaseRule + if err := q.Order("updated_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return ListResponse{}, internal(err) + } + currentID := uint64(0) + var setting models.PurchaseRuleSetting + if err := s.DB.WithContext(ctx).First(&setting, 1).Error; err == nil { + currentID = setting.RuleID + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return ListResponse{}, internal(err) + } + items := make([]RuleItem, 0, len(rows)) + for _, row := range rows { + value, err := item(row, row.ID == currentID) + if err != nil { + return ListResponse{}, err + } + items = append(items, value) + } + return ListResponse{Items: items, Total: total, Page: page, PageSize: size}, nil +} + +func (s *Service) Current(ctx context.Context) (RuleItem, error) { + record, _, _, err := CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if err != nil { + return RuleItem{}, err + } + return item(record, true) +} + +func (s *Service) Create(ctx context.Context, req SaveRequest) (SaveResponse, error) { + name, content, err := validateSave(req) + if err != nil { + return SaveResponse{}, err + } + var replay models.PurchaseRule + db := s.DB.WithContext(ctx) + if err := db.Where("last_create_request_id = ?", req.RequestID).First(&replay).Error; err == nil { + value, e := item(replay, false) + return SaveResponse{Rule: value, Replayed: true}, e + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internal(err) + } + record := models.PurchaseRule{Name: name, ContentJSON: content, LastCreateRequestID: &req.RequestID} + if err := db.Create(&record).Error; err != nil { + return SaveResponse{}, internal(err) + } + value, err := item(record, false) + return SaveResponse{Rule: value}, err +} + +func (s *Service) Update(ctx context.Context, id uint64, req SaveRequest) (SaveResponse, error) { + if id == 0 { + return SaveResponse{}, invalid("ruleId 无效") + } + name, content, err := validateSave(req) + if err != nil { + return SaveResponse{}, err + } + db := s.DB.WithContext(ctx) + var replay models.PurchaseRule + if err := db.Where("id = ? AND last_update_request_id = ?", id, req.RequestID).First(&replay).Error; err == nil { + value, e := item(replay, false) + return SaveResponse{Rule: value, Replayed: true}, e + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return SaveResponse{}, internal(err) + } + r := db.Model(&models.PurchaseRule{}).Where("id = ?", id).Updates(map[string]any{"name": name, "content_json": content, "last_update_request_id": req.RequestID}) + if r.Error != nil { + return SaveResponse{}, internal(r.Error) + } + if r.RowsAffected == 0 { + return SaveResponse{}, notFound() + } + var updated models.PurchaseRule + if err := db.First(&updated, id).Error; err != nil { + return SaveResponse{}, internal(err) + } + value, err := item(updated, false) + return SaveResponse{Rule: value}, err +} + +func (s *Service) Delete(ctx context.Context, id uint64, req DeleteRequest) (bool, error) { + if id == 0 { + return false, invalid("ruleId 无效") + } + if err := validateRequestID(req.RequestID); err != nil { + return false, err + } + db := s.DB.WithContext(ctx) + var replay models.PurchaseRule + if err := db.Unscoped().Where("id = ? AND last_delete_request_id = ?", id, req.RequestID).First(&replay).Error; err == nil { + return true, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return false, internal(err) + } + err := db.Transaction(func(tx *gorm.DB) error { + var setting models.PurchaseRuleSetting + if err := tx.First(&setting, 1).Error; err == nil && setting.RuleID == id { + return conflict("当前采购规则不能删除,请先切换规则") + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return internal(err) + } + var row models.PurchaseRule + if err := tx.First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound() + } + return internal(err) + } + if err := tx.Model(&row).Update("last_delete_request_id", req.RequestID).Error; err != nil { + return internal(err) + } + return tx.Delete(&row).Error + }) + return false, err +} + +func (s *Service) SetCurrent(ctx context.Context, req SetCurrentRequest) (RuleItem, bool, error) { + if err := validateRequestID(req.RequestID); err != nil { + return RuleItem{}, false, err + } + if req.RuleID == 0 { + return RuleItem{}, false, invalid("ruleId 无效") + } + db := s.DB.WithContext(ctx) + replayed := false + err := db.Transaction(func(tx *gorm.DB) error { + var current models.PurchaseRuleSetting + if err := tx.Where("last_update_request_id = ?", req.RequestID).First(¤t).Error; err == nil { + if current.RuleID != req.RuleID { + return conflict("requestId 已用于其他当前规则设置") + } + replayed = true + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return internal(err) + } + var row models.PurchaseRule + if err := tx.First(&row, req.RuleID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return notFound() + } + return internal(err) + } + if _, err := purchasecontract.Validate([]byte(row.ContentJSON), models.PurchaseExecutionModeLive); err != nil { + return invalidRule(err.Error()) + } + setting := models.PurchaseRuleSetting{ID: 1, RuleID: req.RuleID, LastUpdateRequestID: &req.RequestID} + return tx.Save(&setting).Error + }) + if err != nil { + return RuleItem{}, false, err + } + var row models.PurchaseRule + if err := db.First(&row, req.RuleID).Error; err != nil { + return RuleItem{}, false, internal(err) + } + value, err := item(row, true) + return value, replayed, err +} + +func validateSave(req SaveRequest) (string, string, error) { + if err := validateRequestID(req.RequestID); err != nil { + return "", "", err + } + name := strings.TrimSpace(req.Name) + if name == "" || len(name) > 120 { + return "", "", invalid("name 必填且长度不能超过 120") + } + if len(req.Content) == 0 || len(req.Content) > 256<<10 { + return "", "", invalidRule("content 必填且不能超过 256 KiB") + } + rule, err := purchasecontract.Validate(req.Content, models.PurchaseExecutionModeLive) + if err != nil { + return "", "", invalidRule(err.Error()) + } + normalized, err := json.Marshal(rule) + if err != nil { + return "", "", internal(err) + } + return name, string(normalized), nil +} +func validateRequestID(id string) error { + if _, err := uuid.Parse(strings.TrimSpace(id)); err != nil { + return invalid("requestId 必须是 UUID") + } + return nil +} +func item(row models.PurchaseRule, current bool) (RuleItem, error) { + raw := json.RawMessage(row.ContentJSON) + if !json.Valid(raw) { + return RuleItem{}, internal(errors.New("stored purchase rule is invalid JSON")) + } + return RuleItem{ID: row.ID, Name: row.Name, Content: raw, IsCurrent: current, CreatedAt: row.CreatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), UpdatedAt: row.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")}, nil +} +func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} } +func invalidRule(m string) error { return &ServiceError{Code: CodeRuleInvalid, Message: m} } +func notFound() error { return &ServiceError{Code: CodeRuleNotFound, Message: "采购规则不存在"} } +func currentMissing(m string) error { return &ServiceError{Code: CodeCurrentRuleMissing, Message: m} } +func conflict(m string) error { return &ServiceError{Code: CodeCurrentRuleConflict, Message: m} } +func internal(err error) error { + return &ServiceError{Code: CodeInternal, Message: "服务端处理失败", Retryable: true, Cause: err} +} diff --git a/server/app/goauto/purchaserule/service_test.go b/server/app/goauto/purchaserule/service_test.go new file mode 100644 index 0000000..7de6bee --- /dev/null +++ b/server/app/goauto/purchaserule/service_test.go @@ -0,0 +1,67 @@ +package purchaserule + +import ( + "context" + "errors" + "testing" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func purchaseRuleTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = migrations.Migrate(db); err != nil { + t.Fatal(err) + } + return db +} + +func TestCurrentRuleRequiresConfiguredValidRule(t *testing.T) { + db := purchaseRuleTestDB(t) + _, _, _, err := CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive) + var serviceErr *ServiceError + if !errors.As(err, &serviceErr) || serviceErr.Code != CodeCurrentRuleMissing { + t.Fatalf("missing current rule error=%v", err) + } + record := models.PurchaseRule{Name: "损坏规则", ContentJSON: `{}`} + if err = db.Create(&record).Error; err != nil { + t.Fatal(err) + } + if err = db.Create(&models.PurchaseRuleSetting{ID: 1, RuleID: record.ID}).Error; err != nil { + t.Fatal(err) + } + _, _, _, err = CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive) + if !errors.As(err, &serviceErr) || serviceErr.Code != CodeCurrentRuleMissing { + t.Fatalf("invalid current rule error=%v", err) + } +} + +func TestCreateAndSwitchCurrentRule(t *testing.T) { + db := purchaseRuleTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), Name: "正式规则", Content: purchasecontract.DefaultLiveRule()}) + if err != nil { + t.Fatal(err) + } + current, replayed, err := service.SetCurrent(context.Background(), SetCurrentRequest{RequestID: uuid.NewString(), RuleID: created.Rule.ID}) + if err != nil || replayed || !current.IsCurrent { + t.Fatalf("set current: %+v replayed=%v err=%v", current, replayed, err) + } + _, raw, _, err := CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive) + if err != nil || string(raw) != string(purchasecontract.DefaultLiveRule()) { + t.Fatalf("current bytes=%s err=%v", raw, err) + } + if _, err = service.Delete(context.Background(), created.Rule.ID, DeleteRequest{RequestID: uuid.NewString()}); err == nil { + t.Fatal("current rule deletion must be rejected") + } +} diff --git a/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules.go b/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules.go new file mode 100644 index 0000000..7f2d2d4 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules.go @@ -0,0 +1,47 @@ +package version_local + +import ( + "runtime" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseRules) +} + +func migratePurchaseRules(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := migrations.Migrate(tx); err != nil { + return err + } + var setting models.PurchaseRuleSetting + if err := tx.First(&setting, 1).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return err + } + record := models.PurchaseRule{Name: "默认 PDD 正式采购规则", ContentJSON: string(purchasecontract.DefaultLiveRule())} + if err := tx.Create(&record).Error; err != nil { + return err + } + setting = models.PurchaseRuleSetting{ID: 1, RuleID: record.ID} + if err := tx.Create(&setting).Error; err != nil { + return err + } + } + if err := ensureGoAutoMenusAndPurchaserBindings(tx); err != nil { + return err + } + if err := ensureGoAutoMenuGroups(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules_test.go b/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules_test.go new file mode 100644 index 0000000..75f5629 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787983600000_purchase_rules_test.go @@ -0,0 +1,44 @@ +package version_local + +import ( + "testing" + + goautomodels "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + migrationmodels "go-admin/cmd/migrate/migration/models" + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestMigratePurchaseRulesSeedsExactDefaultAndMenu(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:purchase-rules?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&common.Migration{}, &migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &purchaserCasbinRule{}); err != nil { + t.Fatal(err) + } + if err = migratePurchaseRules(db, "1787983600000"); err != nil { + t.Fatal(err) + } + var setting goautomodels.PurchaseRuleSetting + if err = db.Preload("Rule").First(&setting, 1).Error; err != nil { + t.Fatal(err) + } + if setting.Rule.ContentJSON != string(purchasecontract.DefaultLiveRule()) { + t.Fatal("seeded purchase rule does not equal DefaultLiveRule bytes") + } + var menu migrationmodels.SysMenu + if err = db.Where("menu_name = ?", "GoAutoPurchaseRules").First(&menu).Error; err != nil { + t.Fatal(err) + } + var parent migrationmodels.SysMenu + if err = db.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil { + t.Fatal(err) + } + if menu.ParentId != parent.MenuId { + t.Fatalf("purchase rule menu parent=%d want=%d", menu.ParentId, parent.MenuId) + } +} diff --git a/web/src/api/goauto/purchase-rules.js b/web/src/api/goauto/purchase-rules.js new file mode 100644 index 0000000..bdf9f62 --- /dev/null +++ b/web/src/api/goauto/purchase-rules.js @@ -0,0 +1,7 @@ +import request from '@/utils/request' + +export function listPurchaseRules(params) { return request({ url: '/api/admin/v1/purchase-rules', method: 'get', params }) } +export function createPurchaseRule(data) { return request({ url: '/api/admin/v1/purchase-rules', method: 'post', data }) } +export function updatePurchaseRule(ruleId, data) { return request({ url: `/api/admin/v1/purchase-rules/${ruleId}`, method: 'patch', data }) } +export function deletePurchaseRule(ruleId, data) { return request({ url: `/api/admin/v1/purchase-rules/${ruleId}`, method: 'delete', data }) } +export function setCurrentPurchaseRule(data) { return request({ url: '/api/admin/v1/purchase-rules/current', method: 'put', data }) } diff --git a/web/src/views/goauto/purchase-rules/index.vue b/web/src/views/goauto/purchase-rules/index.vue new file mode 100644 index 0000000..2d5fdec --- /dev/null +++ b/web/src/views/goauto/purchase-rules/index.vue @@ -0,0 +1,66 @@ + + + + +