From 2b01974fe5dd4d658a3b20dc70a4e2156a3fdb1e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 10 Sep 2026 15:47:27 +0800 Subject: [PATCH] feat(syb): confirm one-click replacement from latest collection #258 --- server/app/admin/router/init_router.go | 2 +- server/app/goauto/purchase/quick_replace.go | 44 ++++++ .../app/goauto/purchase/quick_replace_test.go | 44 ++++++ server/app/goauto/shopeeproduct/handler.go | 6 +- .../goauto/shopeeproduct/latest_collection.go | 81 ++++++++-- .../shopeeproduct/quick_replace_test.go | 145 ++++++++++++++++++ server/app/goauto/shopeeproduct/router.go | 4 +- server/app/goauto/shopeeproduct/service.go | 7 +- web/src/views/goauto/syb-products/index.vue | 6 +- .../views/goauto/syb-products/quick-link.js | 60 ++++++-- web/tests/e2e/syb-quick-link.spec.ts | 34 +++- web/tests/unit/syb-quick-link.test.cjs | 46 ++++++ 12 files changed, 445 insertions(+), 34 deletions(-) create mode 100644 server/app/goauto/purchase/quick_replace.go create mode 100644 server/app/goauto/purchase/quick_replace_test.go create mode 100644 server/app/goauto/shopeeproduct/quick_replace_test.go diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index ea5fbe9..ac907ca 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -63,7 +63,7 @@ func InitRouter() { goautoproduct.InitRouter(r, authMiddleware) goautoreplacement.InitRouter(r, authMiddleware) goautorule.InitRouter(r, authMiddleware) - goautoshopeeproduct.InitRouter(r, authMiddleware) + goautoshopeeproduct.InitRouter(r, authMiddleware, goautopurchase.ValidateQuickReplacement) goautosybimport.InitRouter(r, authMiddleware) goautosybinnercode.InitRouter(r, authMiddleware) goautosybshop.InitRouter(r, authMiddleware) diff --git a/server/app/goauto/purchase/quick_replace.go b/server/app/goauto/purchase/quick_replace.go new file mode 100644 index 0000000..7d858ed --- /dev/null +++ b/server/app/goauto/purchase/quick_replace.go @@ -0,0 +1,44 @@ +package purchase + +import ( + "context" + + "go-admin/app/goauto/shopeeproduct" + "gorm.io/gorm" +) + +// Called inside the link transaction, after locking the SYB and Shopee rows. +// Reuse the authoritative stage calculation, without AI or task creation. +func ValidateQuickReplacement(ctx context.Context, tx *gorm.DB, id uint64) error { + s := NewService(tx) + dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) + if err != nil { + return err + } + guard, _, err := s.readOnlyPriceGuard(ctx) + if err != nil { + return err + } + preview := s.previewFromDataset(id, dataset, guard) + qualification := aiMatchQualificationForDataset(id, dataset) + preview.AIMatchEligible = qualification.Eligible + preview.AIMatchDisabledReason = qualification.DisabledReason + stage := processStageFromDataset(id, dataset, preview) + syb := dataset.sybByID[id] + disabled := false + if syb.ShopeeProductID != nil { + shopee := dataset.shopeeByID[*syb.ShopeeProductID] + if shopee.PDDProductID != nil { + pdd := dataset.pddByID[*shopee.PDDProductID] + disabled = pdd.Status == "disabled" + if _, active := dataset.activeCollectionByPDD[pdd.ID]; active { + return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "商品正在采集,请结束后刷新再操作"} + } + } + } + if stage.NextAction == "open_pdd" && (stage.Stage == ProcessStagePDDPending || stage.Stage == ProcessStagePDDCollectionFail || + (stage.Stage == ProcessStageManualAction && (disabled || preview.ReasonCode == "PDD_PRICE_MISSING"))) { + return nil + } + return &shopeeproduct.ServiceError{Code: shopeeproduct.CodeLinkConflict, Message: "处理阶段已变化,当前不能一键替换,请刷新列表"} +} diff --git a/server/app/goauto/purchase/quick_replace_test.go b/server/app/goauto/purchase/quick_replace_test.go new file mode 100644 index 0000000..09fdf6d --- /dev/null +++ b/server/app/goauto/purchase/quick_replace_test.go @@ -0,0 +1,44 @@ +package purchase + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func TestQuickReplacementUsesCurrentStage(t *testing.T) { + for _, name := range []string{"pending", "disabled", "price-missing", "ready", "parse-failed", "collecting", "purchase-pending", "purchase-running", "order-created", "order-unknown"} { + t.Run(name, func(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + allow := name == "pending" || name == "disabled" || name == "price-missing" + switch name { + case "pending", "disabled": + db.Model(&f.pdd).Update("status", name) + case "ready": + setCollectedPDDPrice(t, db, f.pdd.ID) + case "parse-failed": + db.Model(&f.syb).Update("parse_status", "failed") + case "collecting": + rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.CollectionTask{DeviceID: &f.device.ID, PDDProductID: &f.pdd.ID, RuleID: rule.ID, Status: models.TaskStatusRunning, RuleSnapshot: "{}"}).Error; err != nil { + t.Fatal(err) + } + case "purchase-pending", "purchase-running", "order-created", "order-unknown": + status := map[string]string{"purchase-pending": "pending", "purchase-running": "running", "order-created": "order_created", "order-unknown": "order_result_unknown"}[name] + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&models.PurchaseTask{ExecutionMode: "live", Quantity: 1, SYBProductID: &f.syb.ID, PDDProductID: f.pdd.ID, Status: status, CreateRequestID: name}).Error; err != nil { + t.Fatal(err) + } + } + err := ValidateQuickReplacement(context.Background(), db, f.syb.ID) + if (err == nil) != allow { + t.Fatalf("allowed=%v: %v", allow, err) + } + }) + } +} diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go index 63520b9..9624744 100644 --- a/server/app/goauto/shopeeproduct/handler.go +++ b/server/app/goauto/shopeeproduct/handler.go @@ -14,7 +14,10 @@ import ( "gorm.io/gorm" ) -type Handler struct{ DB *gorm.DB } +type Handler struct { + DB *gorm.DB + ReplacementEligibility ReplacementEligibility +} func (handler Handler) List(c *gin.Context) { page, err := queryInt(c.Query("page"), 1) @@ -106,6 +109,7 @@ func (handler Handler) LinkPDD(c *gin.Context) { if !ok { return } + service.ReplacementEligibility = handler.ReplacementEligibility response, err := service.LinkPDD(c.Request.Context(), id, request) respond(c, response, err) } diff --git a/server/app/goauto/shopeeproduct/latest_collection.go b/server/app/goauto/shopeeproduct/latest_collection.go index 4f7026d..01b1465 100644 --- a/server/app/goauto/shopeeproduct/latest_collection.go +++ b/server/app/goauto/shopeeproduct/latest_collection.go @@ -13,9 +13,18 @@ const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE" const CodeLinkConflict = "PDD_LINK_CONFLICT" type LatestCollectionRequest struct { - SYBProductID uint64 `json:"sybProductId"` - DeviceID uint64 `json:"deviceId"` - SpecContextVersion string `json:"specContextVersion"` + SYBProductID uint64 `json:"sybProductId"` + DeviceID uint64 `json:"deviceId"` + SpecContextVersion string `json:"specContextVersion"` + Replacement *LatestCollectionReplacement `json:"replacement,omitempty"` +} + +type LatestCollectionReplacement struct { + Preview bool `json:"preview"` + ExpectedPDDProductID uint64 `json:"expectedPddProductId"` + TaskID uint64 `json:"taskId"` + TargetPDDProductID uint64 `json:"targetPddProductId"` + TargetVersion string `json:"targetVersion"` } type LatestCollectionSource struct { @@ -23,6 +32,12 @@ type LatestCollectionSource struct { DeviceID uint64 `json:"deviceId"` PDDProductID uint64 `json:"pddProductId"` RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"` + TargetVersion string `json:"targetVersion,omitempty"` + Title string `json:"title,omitempty"` + GoodsID string `json:"goodsId,omitempty"` + PreviousTitle string `json:"previousTitle,omitempty"` + PreviousGoodsID string `json:"previousGoodsId,omitempty"` + DeviceName string `json:"deviceName,omitempty"` } func latestUnavailable(message string) error { @@ -56,10 +71,28 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req } return err } - if shopee.PDDProductID != nil { + replacement := r.Replacement + var old models.PDDProduct + if replacement != nil { + if shopee.PDDProductID == nil || replacement.ExpectedPDDProductID == 0 || *shopee.PDDProductID != replacement.ExpectedPDDProductID { + return contextVersionStale() + } + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&old, *shopee.PDDProductID).Error; err != nil { + return err + } + if service.ReplacementEligibility == nil { + return invalidRequest("一键替换入口未配置") + } + if err := service.ReplacementEligibility(ctx, tx, syb.ID); err != nil { + return err + } + if !replacement.Preview && (replacement.TaskID == 0 || replacement.TargetPDDProductID == 0 || replacement.TargetVersion == "") { + return invalidRequest("请先预览并确认替换商品") + } + } else if shopee.PDDProductID != nil { return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"} } - if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion { + if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, old.SpecsJSON) != r.SpecContextVersion { return contextVersionStale() } var device models.AgentDevice @@ -73,7 +106,11 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req return latestUnavailable("手机已停用,请重新选择") } var task models.CollectionTask - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("finished_at DESC, id DESC").First(&task).Error + query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}) + if replacement != nil && !replacement.Preview { + query = query.Where("id = ?", replacement.TaskID) + } + err := query.Order("finished_at DESC, id DESC").First(&task).Error if errors.Is(err, gorm.ErrRecordNotFound) { return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品") } @@ -101,18 +138,40 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req if err != nil || len(color)+len(size) == 0 { return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采") } - result := tx.Model(&models.ShopeeProduct{}).Where("id = ? AND pdd_product_id IS NULL", id).Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { + targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON) + if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) { return contextVersionStale() } + // Preview never writes. Same-product continuation also preserves the + // existing association and request marker; matching is a separate call. + if replacement == nil || (!replacement.Preview && old.ID != pdd.ID) { + query := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id) + if replacement == nil { + query = query.Where("pdd_product_id IS NULL") + } else { + query = query.Where("pdd_product_id = ?", old.ID) + } + result := query.Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return contextVersionStale() + } + } response, err = NewService(tx).Detail(ctx, id) if err != nil { return err } response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed} + if replacement != nil { + response.CollectionSource.TargetVersion = targetVersion + response.CollectionSource.Title = pdd.Title + response.CollectionSource.GoodsID = pdd.GoodsID + response.CollectionSource.PreviousTitle = old.Title + response.CollectionSource.PreviousGoodsID = old.GoodsID + response.CollectionSource.DeviceName = device.Name + } return nil }) if err != nil { diff --git a/server/app/goauto/shopeeproduct/quick_replace_test.go b/server/app/goauto/shopeeproduct/quick_replace_test.go new file mode 100644 index 0000000..6e373bf --- /dev/null +++ b/server/app/goauto/shopeeproduct/quick_replace_test.go @@ -0,0 +1,145 @@ +package shopeeproduct + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func replacementFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask, models.PDDProduct) { + s, id, req, task := latestFixture(t) + old := seedPDDProduct(t, s.DB, "pending") + if err := s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", old.ID).Error; err != nil { + t.Fatal(err) + } + detail, err := s.Detail(context.Background(), id) + if err != nil { + t.Fatal(err) + } + req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion + req.LatestCollection.Replacement = &LatestCollectionReplacement{Preview: true, ExpectedPDDProductID: old.ID} + s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return nil } + return s, id, req, task, old +} + +func previewReplacement(t *testing.T, s *Service, id uint64, req *LinkPDDRequest) SaveResponse { + t.Helper() + preview, err := s.LinkPDD(context.Background(), id, *req) + if err != nil { + t.Fatal(err) + } + r := req.LatestCollection.Replacement + r.Preview = false + r.TaskID = preview.CollectionSource.TaskID + r.TargetPDDProductID = preview.CollectionSource.PDDProductID + r.TargetVersion = preview.CollectionSource.TargetVersion + return preview +} + +func TestReplacementPreviewIsReadOnlyAndFreezesCandidate(t *testing.T) { + s, id, req, task, old := replacementFixture(t) + preview := previewReplacement(t, s, id, &req) + if *preview.Product.PDDProductID != old.ID { + t.Fatal("preview changed association") + } + var record models.ShopeeProduct + s.DB.First(&record, id) + if *record.PDDProductID != old.ID || record.LastUpdateRequestID != nil { + t.Fatal("preview wrote") + } + newer := task + newer.ID = 0 + newer.Status = models.TaskStatusCompletedPartial + if err := s.DB.Create(&newer).Error; err != nil { + t.Fatal(err) + } + result, err := s.LinkPDD(context.Background(), id, req) + if err != nil || *result.Product.PDDProductID != *task.PDDProductID || result.CollectionSource.TaskID != task.ID { + t.Fatalf("frozen candidate: %+v %v", result, err) + } + if _, err := s.LinkPDD(context.Background(), id, req); err == nil { + t.Fatal("duplicate replaced stale association") + } +} + +func TestReplacementSameProductDoesNotRebind(t *testing.T) { + s, id, req, task, _ := replacementFixture(t) + s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", *task.PDDProductID) + detail, _ := s.Detail(context.Background(), id) + req.LatestCollection.SpecContextVersion = detail.Product.SpecContextVersion + req.LatestCollection.Replacement.ExpectedPDDProductID = *task.PDDProductID + previewReplacement(t, s, id, &req) + if _, err := s.LinkPDD(context.Background(), id, req); err != nil { + t.Fatal(err) + } + var record models.ShopeeProduct + s.DB.First(&record, id) + if record.LastUpdateRequestID != nil { + t.Fatal("same product wrote association") + } +} + +func TestReplacementRejectsChangesAfterPreview(t *testing.T) { + for _, change := range []string{"association", "specs", "target-specs", "task-status", "device", "stage", "missing-validator", "target-id", "task-id"} { + t.Run(change, func(t *testing.T) { + s, id, req, task, old := replacementFixture(t) + previewReplacement(t, s, id, &req) + expected := old.ID + switch change { + case "association": + other := seedPDDProduct(t, s.DB, "active") + expected = other.ID + s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("pdd_product_id", expected) + case "specs": + s.DB.Model(&models.ShopeeProduct{}).Where("id = ?", id).Update("specs_json", "[]") + case "target-specs": + s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"白色","selectable":true}]}]`) + case "task-status": + s.DB.Model(&task).Update("status", "completed_partial") + case "device": + s.DB.Model(&models.AgentDevice{}).Where("id = ?", req.LatestCollection.DeviceID).Update("status", "disabled") + case "stage": + s.ReplacementEligibility = func(context.Context, *gorm.DB, uint64) error { return &ServiceError{Code: CodeLinkConflict} } + case "missing-validator": + s.ReplacementEligibility = nil + case "target-id": + req.LatestCollection.Replacement.TargetPDDProductID++ + case "task-id": + req.LatestCollection.Replacement.TaskID++ + } + if _, err := s.LinkPDD(context.Background(), id, req); err == nil { + t.Fatal("accepted changed context") + } + var record models.ShopeeProduct + s.DB.First(&record, id) + if *record.PDDProductID != expected { + t.Fatal("overwrote rejected association") + } + }) + } +} + +func TestReplacementConcurrentConfirmationsOnlyOneWrites(t *testing.T) { + s, id, req, _, _ := replacementFixture(t) + previewReplacement(t, s, id, &req) + connection, err := s.DB.DB() + if err != nil { + t.Fatal(err) + } + connection.SetMaxOpenConns(1) // SQLite serialization, not a MySQL lock claim. + results := make(chan error, 2) + for range 2 { + go func() { _, err := s.LinkPDD(context.Background(), id, req); results <- err }() + } + wins := 0 + for range 2 { + if <-results == nil { + wins++ + } + } + if wins != 1 { + t.Fatalf("writes=%d", wins) + } +} diff --git a/server/app/goauto/shopeeproduct/router.go b/server/app/goauto/shopeeproduct/router.go index 5ec039a..f2485a0 100644 --- a/server/app/goauto/shopeeproduct/router.go +++ b/server/app/goauto/shopeeproduct/router.go @@ -7,8 +7,8 @@ import ( jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" ) -func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { - handler := Handler{} +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware, eligibility ReplacementEligibility) { + handler := Handler{ReplacementEligibility: eligibility} admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin")) adminOnlyRuns.POST("", handler.StartAutoMatchRun) diff --git a/server/app/goauto/shopeeproduct/service.go b/server/app/goauto/shopeeproduct/service.go index d5368f4..ab551e8 100644 --- a/server/app/goauto/shopeeproduct/service.go +++ b/server/app/goauto/shopeeproduct/service.go @@ -57,7 +57,12 @@ func (err *ServiceError) Error() string { } func (err *ServiceError) Unwrap() error { return err.Cause } -type Service struct{ DB *gorm.DB } +type ReplacementEligibility func(context.Context, *gorm.DB, uint64) error + +type Service struct { + DB *gorm.DB + ReplacementEligibility ReplacementEligibility +} func NewService(db *gorm.DB) *Service { return &Service{DB: db} } diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 4d598d1..70608c6 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -35,9 +35,9 @@
{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}
+{{ quickPicker.row ? '选择后继续处理刚才的商品;更换为其他商品前会再次确认。' : '只修改各行共用的手机,不启动关联或替换,也不影响正在进行的操作。' }}