fix(#73): speed up SYB purchase preview
This commit is contained in:
@@ -80,11 +80,15 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
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)
|
||||
items := make([]BatchPreviewItem, 0, len(ids))
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
item := s.previewOne(ctx, id)
|
||||
item := s.previewFromDataset(ctx, id, dataset, false)
|
||||
if item.Eligible && deviceErr != nil {
|
||||
item.Eligible = false
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr)
|
||||
@@ -97,6 +101,73 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
return BatchPreviewResponse{Items: items, EligibleCount: eligible, SkippedCount: len(items) - eligible}, nil
|
||||
}
|
||||
|
||||
type batchPreviewDataset struct {
|
||||
sybByID map[uint64]models.SYBProduct
|
||||
shopeeByID map[uint64]models.ShopeeProduct
|
||||
pddByID map[uint64]models.PDDProduct
|
||||
latestTaskBySYB map[uint64]models.PurchaseTask
|
||||
}
|
||||
|
||||
// loadBatchPreviewDataset keeps the read-only preview bounded to four bulk
|
||||
// queries regardless of the number of rows on the current page. Creation still
|
||||
// revalidates each item against current data before persisting a task.
|
||||
func (s *Service) loadBatchPreviewDataset(ctx context.Context, ids []uint64) (batchPreviewDataset, error) {
|
||||
dataset := batchPreviewDataset{
|
||||
sybByID: make(map[uint64]models.SYBProduct, len(ids)),
|
||||
shopeeByID: make(map[uint64]models.ShopeeProduct),
|
||||
pddByID: make(map[uint64]models.PDDProduct),
|
||||
latestTaskBySYB: make(map[uint64]models.PurchaseTask),
|
||||
}
|
||||
var sybProducts []models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", ids).Find(&sybProducts).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
shopeeIDs := make([]uint64, 0, len(sybProducts))
|
||||
for _, syb := range sybProducts {
|
||||
dataset.sybByID[syb.ID] = syb
|
||||
if syb.ShopeeProductID != nil {
|
||||
shopeeIDs = append(shopeeIDs, *syb.ShopeeProductID)
|
||||
}
|
||||
}
|
||||
if len(shopeeIDs) > 0 {
|
||||
var shopeeProducts []models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", shopeeIDs).Find(&shopeeProducts).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
pddIDs := make([]uint64, 0, len(shopeeProducts))
|
||||
for _, shopee := range shopeeProducts {
|
||||
dataset.shopeeByID[shopee.ID] = shopee
|
||||
if shopee.PDDProductID != nil {
|
||||
pddIDs = append(pddIDs, *shopee.PDDProductID)
|
||||
}
|
||||
}
|
||||
if len(pddIDs) > 0 {
|
||||
var pddProducts []models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).Where("id IN ?", pddIDs).Find(&pddProducts).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
for _, pdd := range pddProducts {
|
||||
dataset.pddByID[pdd.ID] = pdd
|
||||
}
|
||||
}
|
||||
}
|
||||
var tasks []models.PurchaseTask
|
||||
if err := s.DB.WithContext(ctx).
|
||||
Where("syb_product_id IN ? AND id = (SELECT MAX(pt2.id) FROM purchase_task pt2 WHERE pt2.syb_product_id = purchase_task.syb_product_id)", ids).
|
||||
Find(&tasks).Error; err != nil {
|
||||
return dataset, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
if task.SYBProductID == nil {
|
||||
continue
|
||||
}
|
||||
if _, exists := dataset.latestTaskBySYB[*task.SYBProductID]; !exists {
|
||||
dataset.latestTaskBySYB[*task.SYBProductID] = task
|
||||
}
|
||||
}
|
||||
return dataset, nil
|
||||
}
|
||||
|
||||
func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (BatchCreateResponse, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
|
||||
return BatchCreateResponse{}, fail(CodeInvalidRequest, "requestId 无效")
|
||||
@@ -188,9 +259,19 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) err
|
||||
}
|
||||
|
||||
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
return s.previewFromDataset(ctx, id, dataset, true)
|
||||
}
|
||||
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool) BatchPreviewItem {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
var syb models.SYBProduct
|
||||
if err := s.DB.WithContext(ctx).First(&syb, id).Error; err != nil {
|
||||
syb, found := dataset.sybByID[id]
|
||||
if !found {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SYB_PRODUCT_NOT_FOUND", "SYB 商品不存在或已被删除", "refresh"
|
||||
return item
|
||||
}
|
||||
@@ -204,8 +285,8 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_LINKED", "尚未关联蝦皮商品,请先重新导入或处理商品档案", "refresh"
|
||||
return item
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
|
||||
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !found {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
|
||||
return item
|
||||
}
|
||||
@@ -214,8 +295,8 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
return item
|
||||
}
|
||||
item.PDDProductID = shopee.PDDProductID
|
||||
var pdd models.PDDProduct
|
||||
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的拼多多商品不存在,请先重新匹配", "open_mapping"
|
||||
return item
|
||||
}
|
||||
@@ -233,12 +314,17 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
}
|
||||
if source == "unresolved" {
|
||||
if archiveUsable {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes})
|
||||
if matchErr != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
|
||||
return item
|
||||
request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
if allowAI {
|
||||
matched, matchErr := s.matcher().Resolve(ctx, request)
|
||||
if matchErr != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr))
|
||||
return item
|
||||
}
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
} else if matched, ok := aimatching.DeterministicMatch(request); ok {
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
}
|
||||
item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize
|
||||
}
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor)
|
||||
@@ -247,9 +333,7 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
return item
|
||||
}
|
||||
item.ReferenceUnitPriceCent, item.MinUnitPriceCent, item.MaxUnitPriceCent, item.Currency = reference, minPrice, maxPrice, "CNY"
|
||||
var previous models.PurchaseTask
|
||||
err = s.DB.WithContext(ctx).Where("syb_product_id = ?", syb.ID).Order("id DESC").First(&previous).Error
|
||||
if err == nil {
|
||||
if previous, found := dataset.latestTaskBySYB[syb.ID]; found {
|
||||
item.ActiveTaskID = &previous.ID
|
||||
switch previous.Status {
|
||||
case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted:
|
||||
@@ -266,9 +350,6 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeStateConflict, "该商品已有未结束的采购任务", "open_task"
|
||||
return item
|
||||
}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
item.ActiveTaskID = nil
|
||||
item.Eligible = true
|
||||
|
||||
@@ -106,6 +106,46 @@ func TestBatchPreviewRejectsIncompatibleDevice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), false)
|
||||
setCollectedPDDPrice(t, db, fixture.pdd.ID)
|
||||
second := fixture.syb
|
||||
second.ID = 0
|
||||
second.OrderCode = "SYB-2"
|
||||
second.DetailID = 2
|
||||
second.TargetColor = "深黑色"
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", fixture.syb.ID).Update("target_color", "深黑色").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &countingRetryMatcher{}
|
||||
service := testService(db)
|
||||
service.Matcher = matcher
|
||||
queries := 0
|
||||
callbackName := "test:count-batch-preview-queries"
|
||||
if err := db.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) { queries++ }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Callback().Query().Remove(callbackName) })
|
||||
|
||||
response, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID, second.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
|
||||
t.Helper()
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
|
||||
|
||||
Reference in New Issue
Block a user