diff --git a/internal/bootstrap/catalogmapping/services_test.go b/internal/bootstrap/catalogmapping/services_test.go index 38da96d0..82a0e72a 100644 --- a/internal/bootstrap/catalogmapping/services_test.go +++ b/internal/bootstrap/catalogmapping/services_test.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" + "strings" "testing" mappingdomain "github.com/dujiao-next/internal/modules/catalog/mapping/domain" @@ -844,3 +846,98 @@ func TestImportUpstreamProductRejectsInactive(t *testing.T) { t.Fatalf("expected no local product created when import rejected, got %d", productCount) } } + +func TestImportUpstreamProductPrefersUpstreamSlug(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:import_upstream_slug?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite failed: %v", err) + } + if err := db.AutoMigrate( + &categorydomain.Category{}, + &productdomain.Product{}, + &productdomain.ProductSKU{}, + &siteconnectiondomain.Connection{}, + &mappingdomain.Mapping{}, + &mappingdomain.SKUMapping{}, + ); err != nil { + t.Fatalf("auto migrate failed: %v", err) + } + categoryRepo := categorygormstore.NewCategoryStore(db) + if err := categoryRepo.Create(&categorydomain.Category{Slug: "c", NameJSON: jsonmap.JSON{"zh-CN": "C"}}); err != nil { + t.Fatalf("create category failed: %v", err) + } + // 已软删除的商品仍占用唯一索引,导入必须避开 + if err := db.Exec("INSERT INTO products (category_id, slug, title_json, price_amount, deleted_at) VALUES (1, 'taken', '{}', 0, CURRENT_TIMESTAMP)").Error; err != nil { + t.Fatalf("seed deleted product failed: %v", err) + } + + upstreamSlugs := map[string]string{ + "/api/v1/upstream/products/1": "telegram-asia-iq964", + "/api/v1/upstream/products/2": "taken", + "/api/v1/upstream/products/3": "Bad Slug!", + "/api/v1/upstream/products/4": "", + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/api/v1/upstream/products/")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "product": upstream.UpstreamProduct{ + ID: uint(id), + Slug: upstreamSlugs[r.URL.Path], + Title: jsonmap.JSON{"zh-CN": "P"}, + PriceAmount: "10.00", + IsActive: true, + }, + }) + })) + defer server.Close() + + connService := siteconnectionapp.NewService(siteconnectiongormstore.New(db), "test-secret-key", t.TempDir()) + conn, err := connService.Create(siteconnectionapp.CreateInput{ + Name: "u", BaseURL: server.URL, ApiKey: "k", ApiSecret: "s", + Protocol: constants.ConnectionProtocolDujiaoNext, + }) + if err != nil { + t.Fatalf("create connection failed: %v", err) + } + productRepo := productgormstore.NewProductStore(db) + svc, err := newTestMappingService( + mappinggormstore.NewMappingStore(db), + mappinggormstore.NewSKUMappingStore(db), + productRepo, + productgormstore.NewSKUStore(db), + categoryRepo, + connService, + noopLocalMediaRecorder{}, + ) + if err != nil { + t.Fatalf("create product mapping service: %v", err) + } + + cases := []struct { + upstreamID uint + want string + }{ + {1, "telegram-asia-iq964"}, + {2, "taken-2"}, + {3, ""}, // 非法 slug → 回退自动生成 + {4, ""}, // 未提供 slug → 回退自动生成 + } + for _, tc := range cases { + mapping, err := svc.ImportUpstreamProduct(conn.ID, tc.upstreamID, 1, "") + if err != nil { + t.Fatalf("import upstream %d failed: %v", tc.upstreamID, err) + } + var product productdomain.Product + if err := db.First(&product, mapping.LocalProductID).Error; err != nil { + t.Fatalf("load product failed: %v", err) + } + if tc.want != "" && product.Slug != tc.want { + t.Fatalf("upstream %d: expected slug %q, got %q", tc.upstreamID, tc.want, product.Slug) + } + if tc.want == "" && !strings.HasPrefix(product.Slug, "upstream-") { + t.Fatalf("upstream %d: expected fallback slug, got %q", tc.upstreamID, product.Slug) + } + } +} diff --git a/internal/modules/catalog/mapping/application/import.go b/internal/modules/catalog/mapping/application/import.go index cbae7fa9..86d8b60c 100644 --- a/internal/modules/catalog/mapping/application/import.go +++ b/internal/modules/catalog/mapping/application/import.go @@ -24,6 +24,25 @@ import ( "github.com/shopspring/decimal" ) +var importSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +// pickImportSlug 依次尝试 上游 slug → slug-上游ID → upstream-连接ID-上游ID-毫秒时间戳。 +func (s *Service) pickImportSlug(upstreamSlug string, connectionID, upstreamProductID uint) (string, error) { + upstreamSlug = strings.ToLower(strings.TrimSpace(upstreamSlug)) + if len(upstreamSlug) <= 100 && importSlugPattern.MatchString(upstreamSlug) { + for _, candidate := range []string{upstreamSlug, fmt.Sprintf("%s-%d", upstreamSlug, upstreamProductID)} { + count, err := s.products.CountBySlug(candidate, nil) + if err != nil { + return "", err + } + if count == 0 { + return candidate, nil + } + } + } + return fmt.Sprintf("upstream-%d-%d-%d", connectionID, upstreamProductID, time.Now().UnixMilli()), nil +} + // ImportUpstreamProduct 从上游导入商品(克隆为本地商品 + 建立映射) func (s *Service) ImportUpstreamProduct(connectionID uint, upstreamProductID uint, categoryID uint, slug string) (*mappingdomain.Mapping, error) { return s.importUpstreamProduct(connectionID, upstreamProductID, categoryID, slug, false, nil) @@ -136,9 +155,12 @@ func (s *Service) importUpstreamProduct(connectionID uint, upstreamProductID uin } } - // 自动生成 slug(如果未提供) + // 未指定 slug 时优先沿用上游 slug,不可用再回退自动生成 if slug == "" { - slug = fmt.Sprintf("upstream-%d-%d-%d", connectionID, upstreamProductID, time.Now().UnixMilli()) + slug, err = s.pickImportSlug(upProduct.Slug, connectionID, upstreamProductID) + if err != nil { + return nil, err + } } // 创建本地商品 diff --git a/internal/modules/catalog/mapping/contract/repository.go b/internal/modules/catalog/mapping/contract/repository.go index 83020ec2..fb944d5d 100644 --- a/internal/modules/catalog/mapping/contract/repository.go +++ b/internal/modules/catalog/mapping/contract/repository.go @@ -47,6 +47,7 @@ type ProductRepository interface { GetByID(id string) (*productdomain.Product, error) Update(item *productdomain.Product) error QuickUpdate(id string, fields map[string]interface{}) error + CountBySlug(slug string, excludeID *string) (int64, error) } // SKURepository 是映射上下文所需的最小本地 SKU 端口。 diff --git a/internal/upstream/adapter.go b/internal/upstream/adapter.go index b3c3bbb3..4a998fa9 100644 --- a/internal/upstream/adapter.go +++ b/internal/upstream/adapter.go @@ -54,6 +54,7 @@ type ProductListResult struct { // UpstreamProduct 上游商品信息 type UpstreamProduct struct { ID uint `json:"id"` + Slug string `json:"slug"` SeoMeta jsonmap.JSON `json:"seo_meta"` Title jsonmap.JSON `json:"title"` Description jsonmap.JSON `json:"description"`