feat(syb): confirm one-click replacement from latest collection #258

This commit is contained in:
QiuSW
2026-09-10 15:47:27 +08:00
parent 305062dba0
commit 2b01974fe5
12 changed files with 445 additions and 34 deletions
+1 -1
View File
@@ -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)
@@ -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: "处理阶段已变化,当前不能一键替换,请刷新列表"}
}
@@ -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)
}
})
}
}
+5 -1
View File
@@ -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)
}
@@ -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 {
@@ -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)
}
}
+2 -2
View File
@@ -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)
+6 -1
View File
@@ -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} }