feat: 导入上游商品时优先沿用上游 slug

This commit is contained in:
dujiao-next committed 2026-10-11 12:09:13 +08:00
1 parent 291a2ae2b6
commit 5bd0ce93d6
4 files changed
+123 -2

No files matched your search

@@ -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)
}
}
}
@@ -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
}
}
// 创建本地商品
@@ -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 端口。
+1
View File
@@ -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"`