feat(goauto): add managed purchase rules (#127)

This commit is contained in:
QiuSW
2026-08-29 21:20:55 +08:00
parent 7afe5ae9ce
commit 148ba96521
19 changed files with 833 additions and 24 deletions
+2
View File
@@ -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)
+8 -3
View File
@@ -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"):
+5 -5
View File
@@ -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 {
+7
View File
@@ -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},
+2
View File
@@ -50,6 +50,8 @@ func MigratedModels() []any {
&models.PurchaseTaskAttempt{},
&models.PurchaseSpecMatchWorkItem{},
&models.CollectionRule{},
&models.PurchaseRule{},
&models.PurchaseRuleSetting{},
&models.AgentManualCollectionSetting{},
&models.CollectionTask{},
&models.CollectionTaskAttempt{},
+29
View File
@@ -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
+13 -8
View File
@@ -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))
}
+2 -2
View File
@@ -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)
+3 -3
View File
@@ -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 {
+12 -3
View File
@@ -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 = "原设备当前不可用,请先恢复设备"
@@ -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 {
+182
View File
@@ -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})
}
+18
View File
@@ -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)
}
+312
View File
@@ -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(&current).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}
}
@@ -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")
}
}
@@ -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
})
}
@@ -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)
}
}
+7
View File
@@ -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 }) }
@@ -0,0 +1,66 @@
<template>
<BasicLayout><template #wrapper>
<el-card class="page-card" shadow="never">
<div class="page-heading">
<div><h1>采购规则</h1><p>维护 PDD 采购执行规则。只有标记为“当前”的规则用于新任务,已有任务继续使用自己的规则快照。</p></div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建采购规则</el-button>
</div>
<el-alert type="warning" :closable="false" show-icon title="规则缺失或校验失败时,新建和重试采购任务会被明确阻断,不会回退到内置规则。" />
<el-form :model="query" inline class="search-form" @submit.prevent="load">
<el-form-item label="规则名称"><el-input v-model="query.name" clearable placeholder="输入规则名称" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无采购规则">
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="规则名称" prop="name" min-width="200" />
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前规则</el-tag><span v-else>历史版本</span></template></el-table-column>
<el-table-column label="规则摘要" min-width="360"><template #default="{ row }">{{ summary(row.content) }}</template></el-table-column>
<el-table-column label="更新时间" prop="updatedAt" width="190" />
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="success" :disabled="row.isCurrent || switching === row.id" :loading="switching === row.id" @click="setCurrent(row)">设为当前</el-button>
<el-button link type="danger" :disabled="row.isCurrent" @click="remove(row)">删除</el-button>
</template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="dialog.open" :title="dialog.ruleId ? '编辑采购规则' : '新建采购规则'" width="760px" destroy-on-close>
<el-form ref="form" :model="form" :rules="rules" label-position="top">
<el-form-item label="规则名称" prop="name"><el-input v-model="form.name" maxlength="120" show-word-limit /></el-form-item>
<el-form-item label="规则 JSON" prop="contentText"><el-input v-model="form.contentText" type="textarea" :rows="18" class="json-editor" spellcheck="false" /><div class="field-help">仅支持 schemaVersion=1、ruleType=pddPurchase;服务端会再次执行严格字段和安全动作校验。</div></el-form-item>
</el-form>
<template #footer><el-button :disabled="dialog.saving" @click="dialog.open=false">取消</el-button><el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button></template>
</el-dialog>
</template></BasicLayout>
</template>
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules'
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] }
export default {
name: 'GoAutoPurchaseRules',
setup() { return { Plus, RefreshLeft, Search } },
data() {
const jsonRule = (_r, value, callback) => { try { const parsed = JSON.parse(value); if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1 || parsed.ruleType !== 'pddPurchase') return callback(new Error('请输入有效的 PDD 采购规则 JSON')); callback() } catch (_) { callback(new Error('请输入有效 JSON')) } }
return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', contentText: '' }, rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }}
},
created() { this.load() },
methods: {
async load() { this.loading = true; try { const response = await listPurchaseRules(this.query); this.items = response.data.items; this.total = response.data.total } finally { this.loading = false } },
search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, name: '' }; this.load() },
summary(content) { return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作` },
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', contentText: JSON.stringify(defaultRule, null, 2) } },
openEdit(row) { this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, contentText: JSON.stringify(row.content, null, 2) } },
async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content: JSON.parse(this.form.contentText) }; try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload); ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } },
async setCurrent(row) { await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。`, '切换当前采购规则', { type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } },
async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() }
}
}
</script>
<style lang="scss" scoped>
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;justify-content:space-between;gap:16px;margin-bottom:18px}.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}.page-heading p,.field-help{margin:0;color:#64748b;line-height:1.5}.search-form{margin:18px 0 16px;padding:16px 16px 0;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}:deep(.json-editor textarea){font-family:"Cascadia Code",Consolas,monospace;line-height:1.55}.field-help{margin-top:6px;font-size:13px}@media(max-width:768px){.page-heading{flex-direction:column}}
</style>