diff --git a/server/app/goauto/shopeeproduct/handler.go b/server/app/goauto/shopeeproduct/handler.go index ef0bb42..63520b9 100644 --- a/server/app/goauto/shopeeproduct/handler.go +++ b/server/app/goauto/shopeeproduct/handler.go @@ -96,6 +96,12 @@ func (handler Handler) LinkPDD(c *gin.Context) { writeError(c, invalidRequest("请求 JSON 无效")) return } + // This composite workflow reads SYB and device context. It must not inherit + // a client key's Shopee-only write grant through the shared handler. + if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") { + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"}) + return + } service, ok := handler.service(c) if !ok { return @@ -467,7 +473,7 @@ func writeError(c *gin.Context, err error) { status = http.StatusConflict case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound: status = http.StatusNotFound - case CodePDDProductDisabled, CodeSpecContextStale: + case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict: status = http.StatusConflict case CodeAIUnavailable: status = http.StatusServiceUnavailable diff --git a/server/app/goauto/shopeeproduct/latest_collection.go b/server/app/goauto/shopeeproduct/latest_collection.go new file mode 100644 index 0000000..4f7026d --- /dev/null +++ b/server/app/goauto/shopeeproduct/latest_collection.go @@ -0,0 +1,126 @@ +package shopeeproduct + +import ( + "context" + "errors" + + "go-admin/app/goauto/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +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"` +} + +type LatestCollectionSource struct { + TaskID uint64 `json:"taskId"` + DeviceID uint64 `json:"deviceId"` + PDDProductID uint64 `json:"pddProductId"` + RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"` +} + +func latestUnavailable(message string) error { + return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message} +} + +// Resolve the candidate exactly once under short row locks; never hold a +// transaction while calling AI. The returned context belongs to this write, +// not a later GET that could silently adopt another operator's association. +func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) { + r := request.LatestCollection + if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" { + return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品") + } + var response SaveResponse + err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var syb models.SYBProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return latestUnavailable("SYB 明细不存在,请刷新列表") + } + return err + } + if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id { + return contextVersionStale() + } + var shopee models.ShopeeProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return productNotFound() + } + return err + } + if shopee.PDDProductID != nil { + return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"} + } + if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion { + return contextVersionStale() + } + var device models.AgentDevice + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return latestUnavailable("手机已不存在,请重新选择") + } + return err + } + if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline { + 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 + if errors.Is(err, gorm.ErrRecordNotFound) { + return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品") + } + if err != nil { + return err + } + if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil { + return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品") + } + var pdd models.PDDProduct + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return latestUnavailable("最近采集的 PDD 商品已不存在") + } + return err + } + if pdd.Status != "active" { + return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果") + } + color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor) + if err != nil { + return latestUnavailable("最近采集的 PDD 规格无效,请先补采") + } + size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize) + 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 { + 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} + return nil + }) + if err != nil { + var domain *ServiceError + if errors.As(err, &domain) { + return SaveResponse{}, err + } + return SaveResponse{}, internalError(err) + } + return response, nil +} diff --git a/server/app/goauto/shopeeproduct/latest_collection_test.go b/server/app/goauto/shopeeproduct/latest_collection_test.go new file mode 100644 index 0000000..a7ed3ef --- /dev/null +++ b/server/app/goauto/shopeeproduct/latest_collection_test.go @@ -0,0 +1,194 @@ +package shopeeproduct + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go-admin/app/goauto/models" + "gorm.io/gorm" +) + +func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) { + t.Helper() + db := openTestDB(t) + device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline} + if err := db.Create(&device).Error; err != nil { + t.Fatal(err) + } + rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + pdd := seedPDDProduct(t, db, "active") + s := NewService(db) + created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}}) + if err != nil { + t.Fatal(err) + } + syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID} + if err := db.Create(&syb).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task +} + +func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) { + s, id, req, task := latestFixture(t) + result, err := s.LinkPDD(context.Background(), id, req) + if err != nil { + t.Fatal(err) + } + if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion { + t.Fatalf("invalid frozen response: %+v", result) + } + // Another request (or an ambiguous network retry) cannot overwrite the link. + req.RequestID = uuid.NewString() + if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict { + t.Fatalf("expected conflict: %v", err) + } + other := seedPDDProduct(t, s.DB, "active") + if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil { + t.Fatalf("manual replacement regressed: %v", err) + } +} + +func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) { + cases := []struct { + name string + change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask) + }{ + {"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) { + db.Model(&task).Update("status", "completed_partial") + }}, + {"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { + db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled") + }}, + {"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }}, + {"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) { + db.Model(&task).Update("source", "admin") + }}, + {"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) { + db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled") + }}, + {"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) { + db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]") + }}, + {"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { + r.LatestCollection.SpecContextVersion = "stale" + }}, + {"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { + db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, id, r, task := latestFixture(t) + tc.change(s.DB, &r, task) + if _, err := s.LinkPDD(context.Background(), id, r); err == nil { + t.Fatal("unexpected successful write") + } + var product models.ShopeeProduct + s.DB.First(&product, id) + if product.PDDProductID != nil { + t.Fatal("rejected request wrote association") + } + }) + } +} + +func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) { + s, id, r, first := latestFixture(t) + newer := first + newer.ID = 0 + newer.Status = models.TaskStatusCompletedPartial + if err := s.DB.Create(&newer).Error; err != nil { + t.Fatal(err) + } + if _, err := s.LinkPDD(context.Background(), id, r); err == nil { + t.Fatal("skipped latest partial") + } + // A newer ID with an older completion time must not win. + older := newer + older.ID = 0 + older.Status = models.TaskStatusCompleted + ts := first.FinishedAt.Add(-time.Hour) + older.FinishedAt = &ts + if err := s.DB.Create(&older).Error; err != nil { + t.Fatal(err) + } + if _, err := s.LinkPDD(context.Background(), id, r); err == nil { + t.Fatal("sorted by id instead of completion") + } + if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil { + t.Fatal(err) + } + result, err := s.LinkPDD(context.Background(), id, r) + if err != nil || result.CollectionSource.TaskID != newer.ID { + t.Fatalf("wrong latest result: %+v %v", result, err) + } +} + +func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) { + for _, status := range []string{"success", "uncertain", "failed"} { + t.Run(status, func(t *testing.T) { + s, id, r, _ := latestFixture(t) + s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status) + result, err := s.LinkPDD(context.Background(), id, r) + if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") { + t.Fatalf("parse boundary: %+v %v", result, err) + } + }) + } +} + +func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`))) + if w.Code != http.StatusForbidden { + t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String()) + } +} + +// SQLite has no row-level FOR UPDATE. A single test connection serializes +// transactions to exercise concurrent callers; production MySQL locking must +// additionally be verified in its deployment environment. +func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) { + s, id, r, _ := latestFixture(t) + sqlDB, err := s.DB.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + errors := make(chan error, 2) + start := make(chan struct{}) + for range 2 { + go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }() + } + close(start) + wins := 0 + for range 2 { + err := <-errors + if err == nil { + wins++ + } else if errCode(t, err) != CodeLinkConflict { + t.Fatal(err) + } + } + if wins != 1 { + t.Fatalf("expected one write, got %d", wins) + } +} diff --git a/server/app/goauto/shopeeproduct/service.go b/server/app/goauto/shopeeproduct/service.go index 1d2fc22..d5368f4 100644 --- a/server/app/goauto/shopeeproduct/service.go +++ b/server/app/goauto/shopeeproduct/service.go @@ -76,9 +76,10 @@ type ProductView struct { } type SaveResponse struct { - Product ProductView `json:"product"` - Replayed bool `json:"replayed,omitempty"` - AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"` + Product ProductView `json:"product"` + Replayed bool `json:"replayed,omitempty"` + AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"` + CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"` } type ListItemView struct { @@ -273,8 +274,9 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp // ---------------------------------------------------------------- link PDD type LinkPDDRequest struct { - RequestID string `json:"requestId"` - PDDProductID uint64 `json:"pddProductId"` + RequestID string `json:"requestId"` + PDDProductID uint64 `json:"pddProductId"` + LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"` } // LinkPDD sets or changes the linked PDD product via the search-and-select @@ -285,6 +287,9 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil { return SaveResponse{}, invalidRequest("requestId 必须是 UUID") } + if request.LatestCollection != nil { + return service.linkLatestCollection(ctx, id, request) + } if request.PDDProductID == 0 { return SaveResponse{}, invalidRequest("pddProductId 必填") } diff --git a/web/src/api/goauto/shopee-products.js b/web/src/api/goauto/shopee-products.js index be3bdcd..11c06c3 100644 --- a/web/src/api/goauto/shopee-products.js +++ b/web/src/api/goauto/shopee-products.js @@ -16,8 +16,8 @@ export function updateShopeeProduct(productId, data) { return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data }) } -export function linkShopeeProductPdd(productId, data) { - return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data }) +export function linkShopeeProductPdd(productId, data, options = {}) { + return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options }) } export function restoreShopeeProduct(productId, data) { diff --git a/web/src/utils/quick-link-device-preference.js b/web/src/utils/quick-link-device-preference.js new file mode 100644 index 0000000..78fd30c --- /dev/null +++ b/web/src/utils/quick-link-device-preference.js @@ -0,0 +1,24 @@ +// Separate from the purchase-device preference; browser origin provides the +// outer isolation and API environment + user identify the inner namespace. +function key(userId) { + const id = Number(userId) + return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null +} + +export function readQuickLinkDevice(userId) { + try { + const name = key(userId) + if (!name) return null + const id = JSON.parse(localStorage.getItem(name) || 'null') + return Number.isSafeInteger(id) && id > 0 ? id : null + } catch { return null } +} + +export function rememberQuickLinkDevice(userId, deviceId) { + try { + const name = key(userId) + if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false + localStorage.setItem(name, JSON.stringify(deviceId)) + return true + } catch { return false } +} diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue index 64bf4ac..4d598d1 100644 --- a/web/src/views/goauto/syb-products/index.vue +++ b/web/src/views/goauto/syb-products/index.vue @@ -25,12 +25,54 @@ - + + + + +

{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}

+ + + + + + + +

使用这台手机最近一次成功的临时采集;离线也可使用已有结果。关联会作用于共用该蝦皮商品的 SYB 明细。

+

{{ quickDeviceError }} 重试

+ +
+
@@ -171,7 +213,8 @@