fix(#73): speed up SYB purchase preview

This commit is contained in:
QiuSW
2026-08-24 11:48:56 +08:00
parent 476ca64616
commit 634bd46729
7 changed files with 257 additions and 45 deletions
+99 -18
View File
@@ -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
+40
View File
@@ -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 {